Step 13 of the gui/app.py + gui/preview.py structural refactor — final move out of app.py. The four entry-point helpers move together because they're a tightly-coupled cluster: run() calls all three of the others (_apply_windows_dark_mode, _load_user_qss, _BASE_POPOUT_OVERLAY_QSS). Splitting them across commits would just add bookkeeping overhead with no bisect benefit. app_runtime.py imports BooruApp from main_window for run()'s instantiation site, plus Qt at module level (the nested _DarkArrowStyle class inside run() needs Qt.PenStyle.NoPen at call time). Otherwise the four helpers are byte-identical to their app.py originals. After this commit app.py is just the original imports header + log + the shim block — every entity that used to live in it now lives in its canonical module. main_gui.py still imports from booru_viewer.gui.app via the shim (`from .app_runtime import run` re-exports it). Commit 14 swaps main_gui.py to the canonical path and deletes app.py.
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""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())
|