Source code for controller.ui.emergency_caption_dialog

# -*- coding: utf-8 -*-
"""
:File: EuljiroWorship/controller/ui/emergency_caption_dialog.py
:Author: Benjamin Jaedon Choi - https://github.com/saintbenjamin
:Affiliated Church: The Eulji-ro Presbyterian Church [대한예수교장로회(통합) 을지로교회]
:Address: The Eulji-ro Presbyterian Church, 24-10, Eulji-ro 20-gil, Jung-gu, Seoul 04549, South Korea
:Telephone: +82-2-2266-3070
:E-mail: euljirochurch [at] G.M.A.I.L. (replace [at] with @ and G.M.A.I.L as you understood.)
:License: MIT License with Attribution Requirement (see LICENSE file for details); Copyright (c) 2025 The Eulji-ro Presbyterian Church.

Emergency caption input dialog (`PySide6 <https://pypi.org/project/PySide6/>`_).

This module defines a dialog used by the slide controller to generate emergency
slides on demand. It supports the following workflows:

1. Bible reference lookup
    - User enters a reference string (e.g., "요 3:16") and presses Enter.
    - The dialog previews resolved verse text via :class:`controller.utils.emergency_slide_factory.EmergencySlideFactory`.
    - Pressing Enter again (or confirming) finalizes the generated slides.

2. Keyword search
    - User enters a keyword query and chooses a search mode:
        - AND mode: all tokens must appear
        - Compact mode: whitespace-insensitive substring match
    - Results are shown in a table; double-clicking a result selects it.

If the first input is not recognized as a Bible reference, the dialog can also
build non-Bible emergency slides using a selected slide style (e.g., "lyrics",
"hymn", "respo", "image", "video") and optional preset numbers or referenced
media files.

The dialog returns a list of slide dictionaries via :meth:`controller.ui.emergency_caption_dialog.EmergencyCaptionDialog.get_final_slides`,
ready to be written to the slide output file and broadcast by the controller.
"""

import os
import shutil

from PySide6.QtWidgets import (
    QDialog, QLabel, QLineEdit,
    QTextEdit, QVBoxLayout, QPushButton,
    QComboBox, QHeaderView, QHBoxLayout,
    QRadioButton, QButtonGroup, QTableView,
    QFileDialog, QMessageBox, QWidget,
)
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QTextCursor, QPixmap
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtMultimediaWidgets import QVideoWidget

from controller.utils.emergency_slide_factory import EmergencySlideFactory
from controller.utils.keyword_highlight_delegate import KeywordHighlightDelegate
from controller.utils.keyword_result_model_light import KeywordResultTableModelLight

from core.config import paths, style_map
from core.generator.utils.icon_helpers import set_svg_icon, get_icon_path
from core.utils.bible_parser import parse_reference
from core.utils.bible_keyword_searcher import BibleKeywordSearcher

[docs] class EmergencyCaptionDialog(QDialog): """ `PySide6 <https://pypi.org/project/PySide6/>`_ dialog for building emergency slide payloads (Bible verses or custom messages). Supported features: - Bible reference input (e.g., "요 3:16") - First Enter shows a preview generated by :class:`controller.utils.emergency_slide_factory.EmergencySlideFactory`. - Subsequent confirmation finalizes the previewed slides. - Keyword search against the selected Bible version - AND mode: all tokens must appear in a verse. - Compact mode: whitespace-insensitive substring match. - Double-clicking a result fills inputs and finalizes slides. - Non-Bible emergency slide generation - User chooses a slide style from :py:data:`core.config.style_map.STYLE_ALIASES`. - For preset-based styles (e.g., "respo" / "hymn"), numeric inputs can load presets. - For media styles (e.g., "image" / "video"), a referenced asset can be selected, copied into the overlay asset directory, and previewed inside the dialog. - Otherwise, a manual slide is created from caption/headline inputs. Generated slides are stored in ``self.finalized_slides`` and can be retrieved via :meth:`get_final_slides`. Attributes: finalized_slides (list[dict]): Slide dictionaries generated by the most recent preview or confirmation. previewed_once (bool): Whether the Bible-reference preview has been shown at least once. versions (list[str]): Available Bible versions discovered from :py:data:`core.config.paths.BIBLE_DATA_DIR`. """
[docs] def __init__(self, parent=None): """ Initialize the emergency caption dialog UI and internal state. This constructs all widgets used for: - Bible reference input and preview - Keyword-based Bible search and result selection - Manual emergency message and style-based slide generation - Conditional media selection and preview for image/video manual slides Args: parent (QDialog | None): Optional parent widget for the dialog. Returns: None """ super().__init__(parent) self.setWindowTitle("대한예수교장로회(통합) 을지로교회 긴급자막 입력 시스템") self.resize(1000, 1000) self.finalized_slides = [] # --- Bible version selector --- self.version_label = QLabel("성경 선택") self.version_dropdown = QComboBox() self.versions = sorted([ fname.replace(".json", "") for fname in os.listdir(paths.BIBLE_DATA_DIR) if fname.endswith(".json") ]) DEFAULT_VERSION = "대한민국 개역개정 (1998)" self.version_dropdown.addItems(self.versions) if DEFAULT_VERSION in self.versions: self.version_dropdown.setCurrentText(DEFAULT_VERSION) else: self.version_dropdown.setCurrentIndex(0) # --- Input field for Bible reference --- self.input1 = QLineEdit() self.input1.setPlaceholderText("예: 요 3:16") self.input1.returnPressed.connect(self.handle_verse_enter) # --- Preview output area (read-only) --- self.preview = QTextEdit() self.preview.setReadOnly(True) self.preview.setPlaceholderText("엔터를 누르면 성경말씀 미리보기가 여기에 표시됩니다.") # --- Input field for keyword search --- self.keyword_input = QLineEdit() self.keyword_input.setPlaceholderText("예: 하나님이 세상을") self.keyword_input.returnPressed.connect(self.handle_keyword_search) self.search_button = QPushButton() set_svg_icon(self.search_button, get_icon_path("search.svg"), size=30) self.search_button.clicked.connect(self.on_keyword_search) self.search_results = QTableView() self.search_model = KeywordResultTableModelLight([]) self.search_results.setModel(self.search_model) self.search_results.doubleClicked.connect(self.on_result_selected) header = self.search_results.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) # --- Default labels/placeholders for the manual section --- self.default_manual_label_text = "기타 긴급 메시지" self.default_input3_placeholder = "제목 또는 번호 (예: 123)" self.default_input2_placeholder = "두 줄 이상의 긴급 메시지나 안내문을 입력하세요..." # --- Input field for additional message / caption --- self.input2 = QTextEdit() self.input2.setPlaceholderText(self.default_input2_placeholder) # --- Button to confirm and output the emergency caption --- self.ok_button = QPushButton() set_svg_icon(self.ok_button, get_icon_path("export.svg"), size=30) self.ok_button.clicked.connect(self.on_confirm_clicked) # --- Style dropdown and single-line input (caption / number / media path) --- self.style_dropdown = QComboBox() self.style_dropdown.addItems(style_map.STYLE_ALIASES.values()) self.input3 = QLineEdit() self.input3.setPlaceholderText(self.default_input3_placeholder) self.media_button = QPushButton("참조") self.media_button.clicked.connect(self.select_media_file) self.media_button.hide() # Horizontal layout for style + caption/path input self.caption_row = QHBoxLayout() self.caption_row.addWidget(self.style_dropdown) self.caption_row.addWidget(self.input3) self.caption_row.addWidget(self.media_button) # Shared status label for selected media path self.media_status = QLabel("") self.media_status.setAlignment(Qt.AlignmentFlag.AlignCenter) self.media_status.setWordWrap(True) self.media_status.hide() # Image preview self.image_preview = QLabel("선택된 그림 없음") self.image_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_preview.setFixedSize(640, 220) self.image_preview.setScaledContents(False) self.image_preview.hide() # Video preview self.video_status = QLabel("선택된 비디오 없음") self.video_status.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_status.hide() self.video_widget = QVideoWidget() self.video_widget.setMinimumHeight(240) self.video_widget.hide() self.video_controls_widget = QWidget() self.video_controls = QHBoxLayout(self.video_controls_widget) self.video_controls.setContentsMargins(0, 0, 0, 0) self.play_button = QPushButton("재생") self.pause_button = QPushButton("일시정지") self.stop_button = QPushButton("정지") self.play_button.clicked.connect(self.play_preview) self.pause_button.clicked.connect(self.pause_preview) self.stop_button.clicked.connect(self.stop_preview) self.video_controls.addWidget(self.play_button) self.video_controls.addWidget(self.pause_button) self.video_controls.addWidget(self.stop_button) self.video_controls_widget.hide() self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) self.player.setAudioOutput(self.audio) self.player.setVideoOutput(self.video_widget) # Insert caption row above the message input layout = QVBoxLayout() layout.addWidget(self.version_label) layout.addWidget(self.version_dropdown) layout.addWidget(QLabel("성경 구절 검색")) layout.addWidget(self.input1) layout.addWidget(self.preview, stretch=2) layout.addWidget(QLabel("키워드 검색")) self.search_row = QHBoxLayout() self.radio_and = QRadioButton("모두 포함") self.radio_compact = QRadioButton("붙여서 검색") self.radio_and.setChecked(True) self.radio_group = QButtonGroup() self.radio_group.addButton(self.radio_and) self.radio_group.addButton(self.radio_compact) self.search_row.addWidget(self.radio_and) self.search_row.addWidget(self.radio_compact) self.search_row.addWidget(self.keyword_input) self.search_row.addWidget(self.search_button) layout.addLayout(self.search_row) layout.addWidget(self.search_results, stretch=2) self.manual_label = QLabel(self.default_manual_label_text) layout.addWidget(self.manual_label) layout.addLayout(self.caption_row) layout.addWidget(self.input2, stretch=1) layout.addWidget(self.media_status) layout.addWidget(self.image_preview, alignment=Qt.AlignmentFlag.AlignHCenter) layout.addWidget(self.video_status) layout.addWidget(self.video_widget) layout.addWidget(self.video_controls_widget) layout.addWidget(self.ok_button) self.setLayout(layout) self.style_dropdown.currentTextChanged.connect(self.update_manual_media_ui) self.input3.textChanged.connect(self.on_manual_media_path_changed) self.update_manual_media_ui(self.style_dropdown.currentText()) # --- Tracks whether preview has been shown --- self.previewed_once = False
[docs] def _current_manual_style_code(self) -> str: """ Return the internal style code for the current manual-style selection. Args: None Returns: str: Internal style code such as ``lyrics``, ``image``, or ``video``. """ from core.config.style_map import REVERSE_ALIASES return REVERSE_ALIASES.get(self.style_dropdown.currentText(), "verse")
[docs] def update_manual_media_ui(self, _style_display: str): """ Update the manual input area when the selected style changes. This method toggles the visibility and wording of the manual-input widgets so that ``image`` and ``video`` styles can reuse the existing manual entry area. It also shows or hides the appropriate preview widgets and reloads previews when a stored media path is already present. Args: _style_display (str): Current display text emitted by the style dropdown. Returns: None """ style_code = self._current_manual_style_code() is_media = style_code in {"image", "video"} self.media_button.setVisible(is_media) if not is_media: self.manual_label.setText(self.default_manual_label_text) self.input3.setPlaceholderText(self.default_input3_placeholder) self.input2.setPlaceholderText(self.default_input2_placeholder) self.media_status.hide() self.image_preview.hide() self.video_status.hide() self.video_widget.hide() self.video_controls_widget.hide() self.stop_preview() return self.manual_label.setText("기타 긴급 미디어") self.input3.setPlaceholderText("미디어 파일 경로") self.input2.setPlaceholderText("부제 / 설명 (선택)") self.media_status.show() if style_code == "image": self.stop_preview() self.video_status.hide() self.video_widget.hide() self.video_controls_widget.hide() self.image_preview.show() if self.input3.text().strip(): self._load_image_preview(self.input3.text().strip()) else: self.image_preview.clear() self.image_preview.setText("선택된 그림 없음") else: self.image_preview.hide() self.video_status.show() self.video_widget.show() self.video_controls_widget.show() if self.input3.text().strip(): self._load_video_preview(self.input3.text().strip()) else: self.video_status.setText("선택된 비디오 없음")
[docs] def on_manual_media_path_changed(self, text: str): """ Refresh media preview when the path input changes. This method only reacts when the currently selected manual style is ``image`` or ``video``. The single-line manual input is interpreted as a media path and the corresponding preview area is refreshed. Args: text (str): Current contents of the single-line manual input. Returns: None """ style_code = self._current_manual_style_code() path = text.strip() if style_code not in {"image", "video"}: return self.media_status.show() self.media_status.setText(path) if not path: if style_code == "image": self.image_preview.clear() self.image_preview.setText("선택된 그림 없음") else: self.stop_preview() self.video_status.setText("선택된 비디오 없음") return if style_code == "image": self._load_image_preview(path) else: self._load_video_preview(path)
[docs] def select_media_file(self): """ Open a file picker for the currently selected media style. The selected file is copied into ``./html/img`` and the relative path is stored in the single-line manual input field. Args: None Returns: None """ style_code = self._current_manual_style_code() if style_code == "image": title = "그림 선택" file_filter = "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)" elif style_code == "video": title = "비디오 선택" file_filter = "Videos (*.mp4 *.mov *.m4v *.webm *.mkv *.avi)" else: return source_path, _ = QFileDialog.getOpenFileName(self, title, "", file_filter) if not source_path: return relative_path = self.copy_media_to_img_folder(source_path).replace("\\", "/") self.input3.setText(relative_path) self.media_status.setToolTip(source_path)
[docs] def copy_media_to_img_folder(self, source_path: str) -> str: """ Copy the selected media file into ``./html/img``. If the target directory does not exist, it is created. If a file with the same name already exists there, the existing file is reused and no second copy is made. Args: source_path (str): Absolute path to the source media file. Returns: str: Relative media path for overlay usage, e.g. ``img/example.png``. """ overlay_root = "." img_dir = os.path.join(overlay_root, "html", "img") os.makedirs(img_dir, exist_ok=True) fname = os.path.basename(source_path) dest_path = os.path.join(img_dir, fname) if not os.path.exists(dest_path): shutil.copy2(source_path, dest_path) return os.path.join("img", fname)
[docs] def _resolve_preview_path(self, raw_path: str) -> str: """ Resolve either an absolute path or an overlay-relative ``img/...`` path. The resolution order is: 1. Existing absolute path 2. Overlay-relative path under ``./html/`` 3. Existing local relative path from the current working directory Args: raw_path (str): Raw path text entered by the user. Returns: str: Absolute path if the file exists; otherwise an empty string. """ candidate = raw_path.strip() if not candidate: return "" if os.path.isabs(candidate) and os.path.exists(candidate): return candidate if candidate.startswith("img/") or candidate.startswith("img\\"): html_relative = os.path.abspath(os.path.join(".", "html", candidate)) if os.path.exists(html_relative): return html_relative local_relative = os.path.abspath(candidate) if os.path.exists(local_relative): return local_relative return ""
[docs] def _load_image_preview(self, path_or_rel: str): """ Load an image preview from either an absolute or relative path. The resolved image is scaled to fit inside the fixed preview box and the resolved absolute path is mirrored into the media status tooltip for operator visibility. Args: path_or_rel (str): Absolute source path or stored relative overlay path. Returns: None """ abs_path = self._resolve_preview_path(path_or_rel) self.image_preview.show() if not abs_path: self.image_preview.clear() self.image_preview.setText("그림 로딩 실패") return pixmap = QPixmap(abs_path) if pixmap.isNull(): self.image_preview.clear() self.image_preview.setText("그림 로딩 실패") return scaled_pixmap = pixmap.scaled( self.image_preview.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.image_preview.clear() self.image_preview.setPixmap(scaled_pixmap) self.image_preview.setToolTip(abs_path) self.media_status.setText(self.input3.text().strip() or abs_path) self.media_status.setToolTip(abs_path)
[docs] def _load_video_preview(self, path_or_rel: str): """ Load a video preview from either an absolute or relative path. The resolved media source is loaded into ``self.player`` and the video preview widgets are shown. If the path cannot be resolved, the preview is stopped and a failure message is displayed instead. Args: path_or_rel (str): Absolute source path or stored relative overlay path. Returns: None """ abs_path = self._resolve_preview_path(path_or_rel) self.video_status.show() self.video_widget.show() self.video_controls_widget.show() if not abs_path: self.stop_preview() self.video_status.setText("비디오 로딩 실패") return self.video_status.setText(os.path.basename(abs_path)) self.video_status.setToolTip(abs_path) self.media_status.setText(self.input3.text().strip() or abs_path) self.media_status.setToolTip(abs_path) try: self.player.setSource(QUrl.fromLocalFile(abs_path)) except Exception: self.video_status.setText("비디오 로딩 실패")
[docs] def play_preview(self): """ Start playing the current video preview. Args: None Returns: None """ if self.player: self.player.play()
[docs] def pause_preview(self): """ Pause the current video preview. Args: None Returns: None """ if self.player: self.player.pause()
[docs] def stop_preview(self): """ Stop the current video preview. Args: None Returns: None """ if self.player: self.player.stop()
[docs] def get_inputs(self): """ Retrieve the current user inputs from the dialog. Returns: tuple[str, str]: A tuple containing: - The Bible reference or caption text from the first input field. - The emergency message or verse text from the message editor. """ return self.input1.text(), self.input2.toPlainText()
[docs] def handle_verse_enter(self): """ Handle Enter key events in the Bible reference input field. On the first Enter press, this generates and displays a preview of the resolved Bible verse or caption. On subsequent Enter presses, the dialog is accepted. Returns: None """ if not self.previewed_once: self.show_preview() self.previewed_once = True else: self.accept()
[docs] def on_confirm_clicked(self): """ Finalize emergency slide generation and close the dialog. If the primary input is recognized as a Bible reference, a preview is generated if not already shown. Otherwise, non-Bible emergency slides are built directly. For media styles, validation failures keep the dialog open so the operator can choose a file or correct the referenced path. Args: None Returns: None """ line1 = self.input1.text().strip() if parse_reference(line1): if not self.previewed_once: self.show_preview() self.previewed_once = True self.accept() else: if self.build_non_bible_slides(): self.accept()
[docs] def show_preview(self): """ Generate and display a preview of the emergency slides. This resolves the current inputs into slide data using :class:`controller.utils.emergency_slide_factory.EmergencySlideFactory` and renders a text preview in the UI. Generated slides are stored internally for later retrieval. Returns: None """ # Retrieve input values line1, line2 = self.get_inputs() factory = EmergencySlideFactory() version = self.version_dropdown.currentText() slides = factory.create_from_input(line1.strip(), line2.strip(), version=version) # Save generated slides (even if empty) self.finalized_slides = slides # Show warning message if no slides were created if not slides: self.preview.setPlainText("⚠️ 말씀을 찾을 수 없습니다.") return # Compose preview content from slide data preview_lines = [] for slide in slides: caption = slide.get("caption", "").strip() headline = slide.get("headline", "").strip() if caption: preview_lines.append(f"{caption}") if headline: preview_lines.append(headline) preview_lines.append("") # Add spacing between slides # Disable second input if recognized as Bible reference if parse_reference(line1.strip()): self.input2.setEnabled(False) self.input2.setPlainText("(성경 구절로 인식되어 이 입력은 무시됩니다)") else: self.input2.setEnabled(True) self.input2.setStyleSheet("") # Display constructed preview text = "\n".join(preview_lines) self.preview.clear() self.preview.setPlainText(text) self.preview.moveCursor(QTextCursor.Start) # Move to top
[docs] def on_result_selected(self, index): """ Handle selection of a Bible verse from the keyword search results. The selected verse reference and text are applied to the input fields, emergency slides are generated, and the dialog is accepted. Args: index (QModelIndex): Model index corresponding to the selected table row. Returns: None """ row = index.row() # Get data from selected row ref_index = self.search_model.index(row, 0) text_index = self.search_model.index(row, 1) caption = self.search_model.data(ref_index, Qt.DisplayRole) headline = self.search_model.data(text_index, Qt.DisplayRole) # Apply selected content to inputs self.input1.setText(caption) self.input2.setPlainText(headline) # Generate slides and accept dialog factory = EmergencySlideFactory() version = self.version_dropdown.currentText() self.finalized_slides = factory.create_from_input(caption, headline, version=version) self.accept()
[docs] def build_non_bible_slides(self) -> bool: """ Build emergency slides from manual inputs and selected style. Depending on the chosen style and caption input, this may: - Load a preset responsive reading or hymn slide. - Create a manual emergency slide with custom caption and text. - Create an image/video slide using a selected media file path. For media styles, the single-line input stores the referenced asset path while the multiline editor stores optional caption/description text. Args: None Returns: bool: ``True`` if the build step completed and the dialog may close. ``False`` if validation failed and the dialog should remain open. """ from core.config.style_map import REVERSE_ALIASES style_display = self.style_dropdown.currentText() style_code = REVERSE_ALIASES.get(style_display, "verse") factory = EmergencySlideFactory() # Media slides: input3 stores the selected media path, input2 stores optional caption. if style_code in {"image", "video"}: media_path = self.input3.text().strip() caption_input = self.input2.toPlainText().strip() if not media_path: QMessageBox.warning( self, "입력 오류", "미디어 파일을 먼저 선택하거나 경로를 입력하세요." ) self.finalized_slides = [] return False self.finalized_slides = factory.create_manual_slide( style=style_code, caption=caption_input, text=media_path ) return True # Existing non-media behavior caption_input = self.input3.text().strip() headline = self.input2.toPlainText().strip() if style_code == "respo" and caption_input.isdigit(): self.finalized_slides = factory.create_from_respo(int(caption_input)) elif style_code == "hymn" and caption_input.isdigit(): self.finalized_slides = factory.create_from_hymn(int(caption_input)) else: if not caption_input and headline: caption_input = "대한예수교장로회(통합) 을지로교회" self.finalized_slides = factory.create_manual_slide( style=style_code, caption=caption_input, text=headline ) return True
[docs] def get_final_slides(self): """ Return the list of finalized slides generated from the last preview. This method provides access to the slide data created during the most recent call to :meth:`show_preview` or :meth:`build_non_bible_slides`. These slides reflect the selected Bible version and parsed reference or manual input. Returns: list[dict]: A list of slide dictionaries generated by the most recent preview, selection, or manual build operation. """ return self.finalized_slides