Move run + style helpers from app.py to app_runtime.py (no behavior change)
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.
This commit is contained in:
parent
da36c4a8f2
commit
af1715708b
@ -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
|
||||
|
||||
317
booru_viewer/gui/app_runtime.py
Normal file
317
booru_viewer/gui/app_runtime.py
Normal file
@ -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())
|
||||
Loading…
x
Reference in New Issue
Block a user