Source code for gui.ui.widget_overlay

# -*- coding: utf-8 -*-
"""
:File: EuljiroBible/gui/ui/widget_overlay.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.

Defines an overlay widget for displaying Bible verses in EuljiroBible.
"""

import os
from PySide6.QtWidgets import QLabel, QWidget, QVBoxLayout
from PySide6.QtCore import Qt, QRect, QFileSystemWatcher, QTimer
from PySide6.QtGui import QFont, QKeyEvent

from gui.config.config_manager import ConfigManager
from gui.utils.logger import log_error_with_dialog

[docs] class WidgetOverlay(QWidget): """ Overlay widget that displays verse output text in a dedicated window. This widget watches (and optionally polls) the configured verse output file (e.g., ``verse_output.txt``) and renders its contents as an on-screen overlay. It supports two display modes: - "fullscreen": frameless, translucent background window intended for projection/overlay use - non-fullscreen/windowed: standard resizable window for testing or local preview The widget applies user-configured font/color/background settings and includes a simple auto-shrinking mechanism to fit long text within the available area. Attributes: mode (str): Display mode ("fullscreen" or windowed variant used by caller). base_font_size (int): Base font size used as the starting point for auto-fit. last_text (str): Last rendered text content used to detect changes. poll_timer (QTimer | None): Polling timer used when polling is enabled. verse_path (str): Path to the verse output file being watched/polled. watcher (QFileSystemWatcher): File watcher for verse_path change notifications. label (QLabel): Central label used to display the verse text. bg_style (str): CSS fragment for background color (rgba) used in the label stylesheet. text_color (str): Current text color in hex (e.g., "#000000"). """
[docs] def __init__(self, font_family, font_size, text_color, bg_color, alpha, mode, geometry, parent=None): """ Initialize the overlay widget and start file watching/polling. Args: font_family (str): Font family used for displayed text. font_size (int): Base font size used as the starting point for auto-fit. text_color (str): Text color in hex (e.g., "#000000"). bg_color (str): Background color in hex (e.g., "#FFFFFF"). alpha (float): Background alpha value used for rgba composition. mode (str): Display mode ("fullscreen" or windowed variant used by caller). geometry (QRect): Initial window geometry. parent (QWidget | None): Optional parent widget. """ super().__init__() self.mode = mode self.base_font_size = font_size settings = ConfigManager.load() self.last_text = "" poll_enabled = settings.get("poll_enabled", True) if poll_enabled: interval = settings.get("poll_interval", 1000) self.poll_timer = QTimer(self) self.poll_timer.timeout.connect(self.poll_file) self.poll_timer.start(interval) self.setGeometry(geometry) if mode == "fullscreen": self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool) self.setAttribute(Qt.WA_TranslucentBackground) self.showFullScreen() else: self.setWindowFlags(Qt.WindowType.Window) self.resize(800, 600) lang = ConfigManager.load().get("last_language", "ko") self.setWindowTitle("대한예수교장로회(통합) 을지로교회" if lang == "ko" else "The Eulji-ro Presbyterian Church (Tonghap)") layout = QVBoxLayout(self) layout.setContentsMargins(50, 50, 50, 50) self.label = QLabel("") self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.label.setWordWrap(True) self.label.setFont(QFont(font_family, font_size, QFont.Weight.Bold)) r, g, b = int(bg_color[1:3], 16), int(bg_color[3:5], 16), int(bg_color[5:7], 16) alpha_val = alpha self.bg_style = f"background-color: rgba({r}, {g}, {b}, {alpha_val}); background-clip: padding-box;" self.text_color = text_color self.apply_stylesheet() layout.addWidget(self.label) self.verse_path = ConfigManager.load().get("output_path", "verse_output.txt") self.watcher = QFileSystemWatcher([self.verse_path]) self.watcher.fileChanged.connect(self.on_file_changed) self.reload_text()
[docs] def apply_settings(self): """ Apply updated display settings from persisted configuration. This reloads font family/size/weight and overlay color/background settings from ConfigManager, applies them to the label, updates the stylesheet, and re-runs auto-fit against the currently displayed text. """ settings = ConfigManager.load() font_family = settings.get("display_font_family", "Arial") font_size = int(settings.get("display_font_size", 36)) font_weight = settings.get("display_font_weight", QFont.Weight.Normal.value) text_color = settings.get("display_text_color", "#000000") bg_color = settings.get("display_bg_color", "#FFFFFF") alpha = settings.get("display_bg_alpha", 1.0) self.base_font_size = font_size font = QFont(font_family, font_size) if font_weight > 99: font.setWeight(QFont.Weight(font_weight)) else: font.setWeight(font_weight) self.label.setFont(font) r, g, b = int(bg_color[1:3], 16), int(bg_color[3:5], 16), int(bg_color[5:7], 16) self.bg_style = f"background-color: rgba({r}, {g}, {b}, {alpha});" self.text_color = text_color self.apply_stylesheet() self.adjust_font_size(self.label.text())
[docs] def apply_stylesheet(self): """ Apply the current text and background style to the label. This composes the label stylesheet from: - text color - background rgba style - padding / border rules depending on fullscreen vs windowed mode """ if self.mode == "fullscreen": border_radius = "border-radius: 30px; padding: 40px;" else: border_radius = "margin: 0px; padding: 0px; border: none;" self.label.setStyleSheet(f""" color: {self.text_color}; {self.bg_style} {border_radius} """)
[docs] def adjust_font_size(self, text): """ Auto-adjust the label font size to fit the given text within the widget. This reduces the font size in steps until the rendered bounding rectangle fits within a fraction of the available widget width/height. Args: text (str): Text content to fit. """ available_width = int(self.width() * 0.8) available_height = int(self.height() * 0.8) font = self.label.font() font_size = self.base_font_size while font_size > 10: font.setPointSize(font_size) self.label.setFont(font) metrics = self.label.fontMetrics() rect = metrics.boundingRect(QRect(0, 0, available_width, available_height), Qt.TextFlag.TextWordWrap, text) # If text fits in the available space, stop reducing size if rect.width() <= available_width and rect.height() <= available_height: break font_size -= 2 # Decrease size and try again self.label.setFont(font)
[docs] def display_text(self, text): """ Display text in the overlay and apply auto-fit sizing. Args: text (str): Text to display. """ self.label.setText(text) self.adjust_font_size(text)
[docs] def read_verse_file(self): """ Read and return the current verse output file contents. Returns: str | None: Stripped file content if read succeeds; otherwise None. """ try: with open(self.verse_path, "r", encoding="utf-8") as f: return f.read().strip() except Exception as e: log_error_with_dialog(e) log_error_with_dialog(f"[WidgetOverlay.read_verse_file] Failed to read verse :File: {self.verse_path}") return None
[docs] def reload_text(self): """ Reload the verse file content and update the overlay display. If the verse file is empty, the overlay closes itself (treated as an intentional "clear overlay" signal). Otherwise, the new text is rendered and cached. """ if os.path.exists(self.verse_path): verse_text = self.read_verse_file() if verse_text is None: return self.last_text = verse_text if verse_text == "": # If verse file is intentionally emptied, close the overlay self.close() else: self.display_text(verse_text)
[docs] def poll_file(self): """ Polling loop callback to detect file changes. When polling is enabled, this periodically reads the verse file and triggers a reload if the contents differ from the last rendered text. """ if os.path.exists(self.verse_path): verse_text = self.read_verse_file() if verse_text is None: return if verse_text != self.last_text: self.reload_text()
[docs] def on_file_changed(self, path): """ Handle file change notifications from QFileSystemWatcher. This re-adds the path to the watcher (to handle certain platform behaviors where the watch can be dropped on write/replace) and reloads the verse content. Args: path (str): Changed file path. """ if os.path.exists(path): self.watcher.addPath(path) self.reload_text()
[docs] def resizeEvent(self, event): """ Handle widget resize events and re-run auto-fit for the currently displayed text. Args: event (QResizeEvent): Resize event. """ super().resizeEvent(event) if hasattr(self, "label") and self.label.text(): self.adjust_font_size(self.label.text())
[docs] def keyPressEvent(self, event: QKeyEvent): """ Handle key press events for overlay control. Pressing ESC closes the overlay window. Other keys are passed to the base class. Args: event (QKeyEvent): Key event. """ if event.key() == Qt.Key_Escape: self.close() else: super().keyPressEvent(event)
[docs] def closeEvent(self, event): """ Handle widget close events and stop active polling/watching resources. Args: event (QCloseEvent): Close event. """ if hasattr(self, 'poll_timer'): self.poll_timer.stop() if hasattr(self, 'watcher'): files = self.watcher.files() if files: self.watcher.removePaths(files) event.accept()