# -*- 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 _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 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 handle_keyword_search(self):
"""
Handle Enter key events in the keyword search input field.
If the keyword input is non-empty, this triggers a keyword-based
Bible search using the current search mode.
Returns:
None
"""
keyword = self.keyword_input.text().strip()
if keyword:
self.on_keyword_search()
[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_keyword_search(self):
"""
Execute a keyword-based Bible search.
This searches the selected Bible version using the chosen
search mode and populates the result table with matches.
Returns:
None
"""
keyword = self.keyword_input.text().strip()
if not keyword or keyword.replace(" ", "") == "":
return
# Determine search mode
mode = "compact" if self.radio_compact.isChecked() else "and"
# Run keyword search using selected version
version = self.version_dropdown.currentText()
searcher = BibleKeywordSearcher(version=version)
results = searcher.search(keyword, mode=mode)
# Update result table and delegate
self.search_model = KeywordResultTableModelLight(results)
self.search_results.setModel(self.search_model)
self.search_results.setItemDelegate(KeywordHighlightDelegate(keywords=keyword.split()))
self.search_results.resizeRowsToContents()
[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