"""Main Qt6 application window.""" from __future__ import annotations import asyncio import logging import os import sys import threading from pathlib import Path from PySide6.QtCore import Qt, QTimer, Signal, QObject, QUrl, Property from PySide6.QtGui import QPixmap, QAction, QKeySequence, QDesktopServices, QShortcut, QColor from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget, QComboBox, QLabel, QPushButton, QStatusBar, QSplitter, QMessageBox, QTextEdit, QMenu, QFileDialog, QSpinBox, QScrollArea, QProgressBar, ) from dataclasses import dataclass, field from ..core.db import Database, Site from ..core.api.base import BooruClient, Post from ..core.api.detect import client_for_type from ..core.cache import download_image, download_thumbnail, cache_size_bytes, evict_oldest, evict_oldest_thumbnails from ..core.config import MEDIA_EXTENSIONS from .grid import ThumbnailGrid from .preview import ImagePreview from .search import SearchBar from .sites import SiteManagerDialog from .bookmarks import BookmarksView from .library import LibraryView 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