diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 9b789e8..d546b5d 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -51,312 +51,10 @@ from .settings import SettingsDialog log = logging.getLogger("booru") -def _apply_windows_dark_mode(app: QApplication) -> None: - """Detect Windows dark mode and apply Fusion dark palette if needed.""" - try: - import winreg - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", - ) - value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") - winreg.CloseKey(key) - if value == 0: - from PySide6.QtGui import QPalette, QColor - app.setStyle("Fusion") - palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(32, 32, 32)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(38, 38, 38)) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(50, 50, 50)) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Button, QColor(51, 51, 51)) - palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) - palette.setColor(QPalette.ColorRole.Link, QColor(0, 120, 215)) - palette.setColor(QPalette.ColorRole.Highlight, QColor(0, 120, 215)) - palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Mid, QColor(51, 51, 51)) - palette.setColor(QPalette.ColorRole.Dark, QColor(25, 25, 25)) - palette.setColor(QPalette.ColorRole.Shadow, QColor(0, 0, 0)) - palette.setColor(QPalette.ColorRole.Light, QColor(60, 60, 60)) - palette.setColor(QPalette.ColorRole.Midlight, QColor(55, 55, 55)) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(127, 127, 127)) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(127, 127, 127)) - app.setPalette(palette) - # Flatten Fusion's 3D look - app.setStyleSheet(app.styleSheet() + """ - QPushButton { - border: 1px solid #555; - border-radius: 2px; - padding: 4px 12px; - } - QPushButton:hover { background-color: #444; } - QPushButton:pressed { background-color: #333; } - QComboBox { - border: 1px solid #555; - border-radius: 2px; - padding: 3px 6px; - } - QComboBox::drop-down { - border: none; - } - QSpinBox { - border: 1px solid #555; - border-radius: 2px; - } - QLineEdit, QTextEdit { - border: 1px solid #555; - border-radius: 2px; - padding: 3px; - color: #fff; - background-color: #191919; - } - QScrollBar:vertical { - background: #252525; - width: 12px; - } - QScrollBar::handle:vertical { - background: #555; - border-radius: 4px; - min-height: 20px; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0; - } - """) - except Exception as e: - log.warning(f"Operation failed: {e}") - - -# Base popout overlay style — always loaded *before* the user QSS so the -# floating top toolbar (`#_slideshow_toolbar`) and bottom video controls -# (`#_slideshow_controls`) get a sane translucent-black-with-white-text -# look on themes that don't define their own overlay rules. Bundled themes -# in `themes/` redefine the same selectors with their @palette colors and -# win on tie (last rule of equal specificity wins in QSS), so anyone using -# a packaged theme keeps the themed overlay; anyone with a stripped-down -# custom.qss still gets a usable overlay instead of bare letterbox. -_BASE_POPOUT_OVERLAY_QSS = """ -QWidget#_slideshow_toolbar, -QWidget#_slideshow_controls { - background: rgba(0, 0, 0, 160); -} -QWidget#_slideshow_toolbar *, -QWidget#_slideshow_controls * { - background: transparent; - color: white; - border: none; -} -QWidget#_slideshow_toolbar QPushButton, -QWidget#_slideshow_controls QPushButton { - background: transparent; - color: white; - border: 1px solid rgba(255, 255, 255, 80); - padding: 2px 6px; -} -QWidget#_slideshow_toolbar QPushButton:hover, -QWidget#_slideshow_controls QPushButton:hover { - background: rgba(255, 255, 255, 30); -} -QWidget#_slideshow_toolbar QSlider::groove:horizontal, -QWidget#_slideshow_controls QSlider::groove:horizontal { - background: rgba(255, 255, 255, 40); - height: 4px; - border: none; -} -QWidget#_slideshow_toolbar QSlider::handle:horizontal, -QWidget#_slideshow_controls QSlider::handle:horizontal { - background: white; - width: 10px; - margin: -4px 0; - border: none; -} -QWidget#_slideshow_toolbar QSlider::sub-page:horizontal, -QWidget#_slideshow_controls QSlider::sub-page:horizontal { - background: white; -} -QWidget#_slideshow_toolbar QLabel, -QWidget#_slideshow_controls QLabel { - background: transparent; - color: white; -} -""" - - -def _load_user_qss(path: Path) -> str: - """Load a QSS file with optional @palette variable substitution. - - Qt's QSS dialect has no native variables, so we add a tiny preprocessor: - - /* @palette - accent: #cba6f7 - bg: #1e1e2e - text: #cdd6f4 - */ - - QWidget { - background-color: ${bg}; - color: ${text}; - selection-background-color: ${accent}; - } - - The header comment block is parsed for `name: value` pairs and any - `${name}` reference elsewhere in the file is substituted with the - corresponding value before the QSS is handed to Qt. This lets users - recolor a bundled theme by editing the palette block alone, without - hunting through the body for every hex literal. - - Backward compatibility: a file without an @palette block is returned - as-is, so plain hand-written Qt-standard QSS still loads unchanged. - Unknown ${name} references are left in place verbatim and logged as - warnings so typos are visible in the log. - """ - import re - text = path.read_text() - palette_match = re.search(r'/\*\s*@palette\b(.*?)\*/', text, re.DOTALL) - if not palette_match: - return text - - palette: dict[str, str] = {} - for raw_line in palette_match.group(1).splitlines(): - # Strip leading whitespace and any leading * from C-style continuation - line = raw_line.strip().lstrip('*').strip() - if not line or ':' not in line: - continue - key, value = line.split(':', 1) - key = key.strip() - value = value.strip().rstrip(';').strip() - # Allow trailing comments on the same line - if '/*' in value: - value = value.split('/*', 1)[0].strip() - if key and value: - palette[key] = value - - refs = set(re.findall(r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}', text)) - missing = refs - palette.keys() - if missing: - log.warning( - f"QSS @palette: unknown vars {sorted(missing)} in {path.name} " - f"— left in place verbatim, fix the @palette block to define them" - ) - - def replace(m): - return palette.get(m.group(1), m.group(0)) - - return re.sub(r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}', replace, text) - - -def run() -> None: - from ..core.config import data_dir - - app = QApplication(sys.argv) - - # Set a stable Wayland app_id so Hyprland and other compositors can - # consistently identify our windows by class (not by title, which - # changes when search terms appear in the title bar). Qt translates - # setDesktopFileName into the xdg-shell app_id on Wayland. - app.setApplicationName("booru-viewer") - app.setDesktopFileName("booru-viewer") - - # mpv requires LC_NUMERIC=C — Qt resets the locale in QApplication(), - # so we must restore it after Qt init but before creating any mpv instances. - import locale - locale.setlocale(locale.LC_NUMERIC, "C") - - # Apply dark mode on Windows 10+ if system is set to dark - if sys.platform == "win32": - _apply_windows_dark_mode(app) - - # Load user custom stylesheet if it exists - custom_css = data_dir() / "custom.qss" - if custom_css.exists(): - try: - # Use Fusion style with arrow color fix - from PySide6.QtWidgets import QProxyStyle - from PySide6.QtGui import QPalette, QColor, QPainter as _P - from PySide6.QtCore import QPoint as _QP - - import re - # Run through the @palette preprocessor (see _load_user_qss - # for the dialect). Plain Qt-standard QSS files without an - # @palette block are returned unchanged. - css_text = _load_user_qss(custom_css) - - # Extract text color for arrows - m = re.search(r'QWidget\s*\{[^}]*?(?:^|\s)color\s*:\s*(#[0-9a-fA-F]{3,8})', css_text, re.MULTILINE) - arrow_color = QColor(m.group(1)) if m else QColor(200, 200, 200) - - class _DarkArrowStyle(QProxyStyle): - """Fusion proxy that draws visible arrows on dark themes.""" - def drawPrimitive(self, element, option, painter, widget=None): - if element in (self.PrimitiveElement.PE_IndicatorSpinUp, - self.PrimitiveElement.PE_IndicatorSpinDown, - self.PrimitiveElement.PE_IndicatorArrowDown, - self.PrimitiveElement.PE_IndicatorArrowUp): - painter.save() - painter.setRenderHint(_P.RenderHint.Antialiasing) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(arrow_color) - r = option.rect - cx, cy = r.center().x(), r.center().y() - s = min(r.width(), r.height()) // 3 - from PySide6.QtGui import QPolygon - if element in (self.PrimitiveElement.PE_IndicatorSpinUp, - self.PrimitiveElement.PE_IndicatorArrowUp): - painter.drawPolygon(QPolygon([ - _QP(cx, cy - s), _QP(cx - s, cy + s), _QP(cx + s, cy + s) - ])) - else: - painter.drawPolygon(QPolygon([ - _QP(cx - s, cy - s), _QP(cx + s, cy - s), _QP(cx, cy + s) - ])) - painter.restore() - return - super().drawPrimitive(element, option, painter, widget) - - app.setStyle(_DarkArrowStyle("Fusion")) - # Prepend the base overlay defaults so even minimal custom.qss - # files get a usable popout overlay. User rules with the same - # selectors come last and win on tie. - app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS + "\n" + css_text) - - # Extract selection color for grid highlight - pal = app.palette() - m = re.search(r'selection-background-color\s*:\s*(#[0-9a-fA-F]{3,8})', css_text) - if m: - pal.setColor(QPalette.ColorRole.Highlight, QColor(m.group(1))) - app.setPalette(pal) - except Exception as e: - log.warning(f"Operation failed: {e}") - else: - # No custom.qss — still install the popout overlay defaults so the - # floating toolbar/controls have a sane background instead of bare - # letterbox color. - app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS) - - # Set app icon (works in taskbar on all platforms) - from PySide6.QtGui import QIcon - # PyInstaller sets _MEIPASS for bundled data - base_dir = Path(getattr(sys, '_MEIPASS', Path(__file__).parent.parent.parent)) - icon_path = base_dir / "icon.png" - if not icon_path.exists(): - icon_path = Path(__file__).parent.parent.parent / "icon.png" - if not icon_path.exists(): - icon_path = data_dir() / "icon.png" - if icon_path.exists(): - app.setWindowIcon(QIcon(str(icon_path))) - - window = BooruApp() - window.show() - sys.exit(app.exec()) - - # -- Refactor compatibility shims (deleted in commit 14) -- from .search_state import SearchState # re-export for refactor compat from .log_handler import LogHandler # re-export for refactor compat from .async_signals import AsyncSignals # re-export for refactor compat from .info_panel import InfoPanel # re-export for refactor compat from .main_window import BooruApp # re-export for refactor compat +from .app_runtime import run # re-export for refactor compat diff --git a/booru_viewer/gui/app_runtime.py b/booru_viewer/gui/app_runtime.py new file mode 100644 index 0000000..6087c0b --- /dev/null +++ b/booru_viewer/gui/app_runtime.py @@ -0,0 +1,317 @@ +"""Application entry point and Qt-style loading.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from .main_window import BooruApp + +log = logging.getLogger("booru") + + +def _apply_windows_dark_mode(app: QApplication) -> None: + """Detect Windows dark mode and apply Fusion dark palette if needed.""" + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + ) + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + winreg.CloseKey(key) + if value == 0: + from PySide6.QtGui import QPalette, QColor + app.setStyle("Fusion") + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(32, 32, 32)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(38, 38, 38)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Button, QColor(51, 51, 51)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + palette.setColor(QPalette.ColorRole.Link, QColor(0, 120, 215)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(0, 120, 215)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Mid, QColor(51, 51, 51)) + palette.setColor(QPalette.ColorRole.Dark, QColor(25, 25, 25)) + palette.setColor(QPalette.ColorRole.Shadow, QColor(0, 0, 0)) + palette.setColor(QPalette.ColorRole.Light, QColor(60, 60, 60)) + palette.setColor(QPalette.ColorRole.Midlight, QColor(55, 55, 55)) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(127, 127, 127)) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(127, 127, 127)) + app.setPalette(palette) + # Flatten Fusion's 3D look + app.setStyleSheet(app.styleSheet() + """ + QPushButton { + border: 1px solid #555; + border-radius: 2px; + padding: 4px 12px; + } + QPushButton:hover { background-color: #444; } + QPushButton:pressed { background-color: #333; } + QComboBox { + border: 1px solid #555; + border-radius: 2px; + padding: 3px 6px; + } + QComboBox::drop-down { + border: none; + } + QSpinBox { + border: 1px solid #555; + border-radius: 2px; + } + QLineEdit, QTextEdit { + border: 1px solid #555; + border-radius: 2px; + padding: 3px; + color: #fff; + background-color: #191919; + } + QScrollBar:vertical { + background: #252525; + width: 12px; + } + QScrollBar::handle:vertical { + background: #555; + border-radius: 4px; + min-height: 20px; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0; + } + """) + except Exception as e: + log.warning(f"Operation failed: {e}") + + +# Base popout overlay style — always loaded *before* the user QSS so the +# floating top toolbar (`#_slideshow_toolbar`) and bottom video controls +# (`#_slideshow_controls`) get a sane translucent-black-with-white-text +# look on themes that don't define their own overlay rules. Bundled themes +# in `themes/` redefine the same selectors with their @palette colors and +# win on tie (last rule of equal specificity wins in QSS), so anyone using +# a packaged theme keeps the themed overlay; anyone with a stripped-down +# custom.qss still gets a usable overlay instead of bare letterbox. +_BASE_POPOUT_OVERLAY_QSS = """ +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} +QWidget#_slideshow_toolbar *, +QWidget#_slideshow_controls * { + background: transparent; + color: white; + border: none; +} +QWidget#_slideshow_toolbar QPushButton, +QWidget#_slideshow_controls QPushButton { + background: transparent; + color: white; + border: 1px solid rgba(255, 255, 255, 80); + padding: 2px 6px; +} +QWidget#_slideshow_toolbar QPushButton:hover, +QWidget#_slideshow_controls QPushButton:hover { + background: rgba(255, 255, 255, 30); +} +QWidget#_slideshow_toolbar QSlider::groove:horizontal, +QWidget#_slideshow_controls QSlider::groove:horizontal { + background: rgba(255, 255, 255, 40); + height: 4px; + border: none; +} +QWidget#_slideshow_toolbar QSlider::handle:horizontal, +QWidget#_slideshow_controls QSlider::handle:horizontal { + background: white; + width: 10px; + margin: -4px 0; + border: none; +} +QWidget#_slideshow_toolbar QSlider::sub-page:horizontal, +QWidget#_slideshow_controls QSlider::sub-page:horizontal { + background: white; +} +QWidget#_slideshow_toolbar QLabel, +QWidget#_slideshow_controls QLabel { + background: transparent; + color: white; +} +""" + + +def _load_user_qss(path: Path) -> str: + """Load a QSS file with optional @palette variable substitution. + + Qt's QSS dialect has no native variables, so we add a tiny preprocessor: + + /* @palette + accent: #cba6f7 + bg: #1e1e2e + text: #cdd6f4 + */ + + QWidget { + background-color: ${bg}; + color: ${text}; + selection-background-color: ${accent}; + } + + The header comment block is parsed for `name: value` pairs and any + `${name}` reference elsewhere in the file is substituted with the + corresponding value before the QSS is handed to Qt. This lets users + recolor a bundled theme by editing the palette block alone, without + hunting through the body for every hex literal. + + Backward compatibility: a file without an @palette block is returned + as-is, so plain hand-written Qt-standard QSS still loads unchanged. + Unknown ${name} references are left in place verbatim and logged as + warnings so typos are visible in the log. + """ + import re + text = path.read_text() + palette_match = re.search(r'/\*\s*@palette\b(.*?)\*/', text, re.DOTALL) + if not palette_match: + return text + + palette: dict[str, str] = {} + for raw_line in palette_match.group(1).splitlines(): + # Strip leading whitespace and any leading * from C-style continuation + line = raw_line.strip().lstrip('*').strip() + if not line or ':' not in line: + continue + key, value = line.split(':', 1) + key = key.strip() + value = value.strip().rstrip(';').strip() + # Allow trailing comments on the same line + if '/*' in value: + value = value.split('/*', 1)[0].strip() + if key and value: + palette[key] = value + + refs = set(re.findall(r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}', text)) + missing = refs - palette.keys() + if missing: + log.warning( + f"QSS @palette: unknown vars {sorted(missing)} in {path.name} " + f"— left in place verbatim, fix the @palette block to define them" + ) + + def replace(m): + return palette.get(m.group(1), m.group(0)) + + return re.sub(r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}', replace, text) + + +def run() -> None: + from ..core.config import data_dir + + app = QApplication(sys.argv) + + # Set a stable Wayland app_id so Hyprland and other compositors can + # consistently identify our windows by class (not by title, which + # changes when search terms appear in the title bar). Qt translates + # setDesktopFileName into the xdg-shell app_id on Wayland. + app.setApplicationName("booru-viewer") + app.setDesktopFileName("booru-viewer") + + # mpv requires LC_NUMERIC=C — Qt resets the locale in QApplication(), + # so we must restore it after Qt init but before creating any mpv instances. + import locale + locale.setlocale(locale.LC_NUMERIC, "C") + + # Apply dark mode on Windows 10+ if system is set to dark + if sys.platform == "win32": + _apply_windows_dark_mode(app) + + # Load user custom stylesheet if it exists + custom_css = data_dir() / "custom.qss" + if custom_css.exists(): + try: + # Use Fusion style with arrow color fix + from PySide6.QtWidgets import QProxyStyle + from PySide6.QtGui import QPalette, QColor, QPainter as _P + from PySide6.QtCore import QPoint as _QP + + import re + # Run through the @palette preprocessor (see _load_user_qss + # for the dialect). Plain Qt-standard QSS files without an + # @palette block are returned unchanged. + css_text = _load_user_qss(custom_css) + + # Extract text color for arrows + m = re.search(r'QWidget\s*\{[^}]*?(?:^|\s)color\s*:\s*(#[0-9a-fA-F]{3,8})', css_text, re.MULTILINE) + arrow_color = QColor(m.group(1)) if m else QColor(200, 200, 200) + + class _DarkArrowStyle(QProxyStyle): + """Fusion proxy that draws visible arrows on dark themes.""" + def drawPrimitive(self, element, option, painter, widget=None): + if element in (self.PrimitiveElement.PE_IndicatorSpinUp, + self.PrimitiveElement.PE_IndicatorSpinDown, + self.PrimitiveElement.PE_IndicatorArrowDown, + self.PrimitiveElement.PE_IndicatorArrowUp): + painter.save() + painter.setRenderHint(_P.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(arrow_color) + r = option.rect + cx, cy = r.center().x(), r.center().y() + s = min(r.width(), r.height()) // 3 + from PySide6.QtGui import QPolygon + if element in (self.PrimitiveElement.PE_IndicatorSpinUp, + self.PrimitiveElement.PE_IndicatorArrowUp): + painter.drawPolygon(QPolygon([ + _QP(cx, cy - s), _QP(cx - s, cy + s), _QP(cx + s, cy + s) + ])) + else: + painter.drawPolygon(QPolygon([ + _QP(cx - s, cy - s), _QP(cx + s, cy - s), _QP(cx, cy + s) + ])) + painter.restore() + return + super().drawPrimitive(element, option, painter, widget) + + app.setStyle(_DarkArrowStyle("Fusion")) + # Prepend the base overlay defaults so even minimal custom.qss + # files get a usable popout overlay. User rules with the same + # selectors come last and win on tie. + app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS + "\n" + css_text) + + # Extract selection color for grid highlight + pal = app.palette() + m = re.search(r'selection-background-color\s*:\s*(#[0-9a-fA-F]{3,8})', css_text) + if m: + pal.setColor(QPalette.ColorRole.Highlight, QColor(m.group(1))) + app.setPalette(pal) + except Exception as e: + log.warning(f"Operation failed: {e}") + else: + # No custom.qss — still install the popout overlay defaults so the + # floating toolbar/controls have a sane background instead of bare + # letterbox color. + app.setStyleSheet(_BASE_POPOUT_OVERLAY_QSS) + + # Set app icon (works in taskbar on all platforms) + from PySide6.QtGui import QIcon + # PyInstaller sets _MEIPASS for bundled data + base_dir = Path(getattr(sys, '_MEIPASS', Path(__file__).parent.parent.parent)) + icon_path = base_dir / "icon.png" + if not icon_path.exists(): + icon_path = Path(__file__).parent.parent.parent / "icon.png" + if not icon_path.exists(): + icon_path = data_dir() / "icon.png" + if icon_path.exists(): + app.setWindowIcon(QIcon(str(icon_path))) + + window = BooruApp() + window.show() + sys.exit(app.exec())