zapret-kvn/xray_fluent/ui/theme.py
loop-uh 2120dafd0c Выход: слушатель системной темы останавливается до разрушения окон
Приложение аварийно завершалось при каждом выходе с темой «как в системе»
(0xc0000409 в Qt6Core.dll, заметно по журналу Windows после автообновлений).
SystemThemeListener из qfluentwidgets навсегда блокируется в синхронном
RegNotifyChangeKeyValue, поэтому dispose_windows оставлял окно с работающим
дочерним QThread, а при возврате из main() оно разрушалось вместе с потоком:
qFatal «QThread: Destroyed while thread is still running». В startup.log
перед каждым таким выходом стоит «Window kept until interpreter exit: ...
SystemThemeListener».

Слушатель теперь ждёт изменения реестра асинхронно, вместе с событием
остановки, и завершается по просьбе; MainWindow.finish_background_shutdown
останавливает его и дожидается потока до dispose_all_widgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:13:09 +03:00

423 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Single source of truth for the application theme.
Responsibilities:
* Semantic color tokens (``success_color()``, ``surface_color()``, ...) that
branch on :func:`qfluentwidgets.isDarkTheme` **lazily at call time** — the
module keeps no theme-dependent module-level values (C7).
* ``DEFAULT_ACCENT`` — the single copy of the default accent color.
* ``on_theme_changed(callback)`` — subscription helper over
``qconfig.themeChanged``.
* ``apply_theme(...)`` — deduplicated ``setTheme``/``setThemeColor`` wrapper.
* ``apply_initial_theme(...)`` — applies the persisted theme *before* the
main window (and any dialogs) are constructed.
* ``sync_system_theme_listener(...)`` — starts/stops the qfluentwidgets
``SystemThemeListener`` when the theme mode is ``"system"``.
* ``stop_system_theme_listener()`` — stops that thread and waits for it; must
run before the windows are destroyed on exit.
"""
from __future__ import annotations
from typing import Callable
from PyQt6.QtGui import QColor
from qfluentwidgets import (
SystemThemeListener,
Theme,
isDarkTheme,
qconfig,
setTheme,
setThemeColor,
themeColor,
)
from ..constants import DEFAULT_ACCENT_COLOR
from ..platform.windows.theme_watch import IS_WINDOWS, ThemeRegistryWatch
# Re-export: the single default accent value lives in constants.py (D1).
DEFAULT_ACCENT = DEFAULT_ACCENT_COLOR
# Accent presets for the settings page (ordered, exactly 8; D6). Hex
# literals of the accent palette are allowed only here inside ui/ (C3).
ACCENT_PRESETS: list[tuple[str, str]] = [
(DEFAULT_ACCENT, "Windows-синий"),
("#00B7C3", "Бирюза"),
("#107C10", "Зелёный"),
("#744DA9", "Фиолетовый"),
("#E3008C", "Розовый"),
("#E81123", "Красный"),
("#F76B0C", "Оранжевый"),
("#5D5A58", "Графит"),
]
# Semantic tokens as (light, dark) hex pairs. Values are plain strings — the
# QColor objects are built lazily inside the token functions (C7).
_PALETTE: dict[str, tuple[str, str]] = {
"success": ("#0F7B0F", "#4CAF50"),
"warning": ("#9D5D00", "#FFB900"),
"error": ("#C42B1C", "#FF6B6B"),
"info": ("#005FB8", "#3498DB"),
"surface": ("#f3f3f3", "#2b2b2b"),
"text": ("#1b1b1b", "#ffffff"),
"text_muted": ("#757575", "#9E9E9E"),
}
# Graph colors as (light, dark) RGBA tuples (alpha channels matter here).
_GRAPH: dict[str, tuple[tuple[int, int, int, int], tuple[int, int, int, int]]] = {
"graph_up": ((15, 123, 15, 255), (0, 220, 120, 255)),
"graph_grid": ((0, 0, 0, 40), (255, 255, 255, 20)),
"graph_bg": ((0, 0, 0, 12), (0, 0, 0, 30)),
"graph_text": ((90, 90, 90, 255), (190, 190, 190, 255)),
}
def token_pair(name: str) -> tuple[str, str]:
"""Return the ``(light_hex, dark_hex)`` pair for a semantic token.
Intended for ``setCustomStyleSheet(widget, light_qss, dark_qss)`` callers
that need both variants regardless of the current theme.
"""
return _PALETTE[name]
def _palette_color(name: str) -> QColor:
light, dark = _PALETTE[name]
return QColor(dark if isDarkTheme() else light)
def _graph_color(name: str) -> QColor:
light, dark = _GRAPH[name]
return QColor(*(dark if isDarkTheme() else light))
def accent_color() -> QColor:
"""Current accent color (falls back to qfluentwidgets themeColor).
Returns a copy — callers may mutate it (alpha, lighter, ...) without
corrupting the color stored inside qconfig.
"""
try:
return QColor(themeColor())
except Exception:
return QColor(DEFAULT_ACCENT)
def accent_soft_bg() -> QColor:
"""Soft accent fill for "active" rows/cells (accent at alpha 38 ≈ 0.15)."""
color = accent_color()
color.setAlpha(38)
return color
def accent_soft_bg_hover() -> QColor:
"""Hover variant of :func:`accent_soft_bg` (accent at alpha 51 = 0.20)."""
color = accent_color()
color.setAlpha(51)
return color
def normalize_accent(value) -> str:
"""Normalize any accent input to "#RRGGBB" (upper case).
Invalid input (empty, None, garbage, short hex) → ``DEFAULT_ACCENT``.
"""
if not value:
return DEFAULT_ACCENT
try:
color = QColor(str(value))
except Exception:
return DEFAULT_ACCENT
if not color.isValid():
return DEFAULT_ACCENT
return color.name().upper()
def success_color() -> QColor:
return _palette_color("success")
def _positive_for(accent: QColor, dark: bool) -> QColor:
# Акцент читаем на фоне: в тёмной теме тёмный акцент осветляем, в
# светлой — слишком светлый затемняем (серый «Графит» тоже остаётся
# различимым).
if dark:
return accent.lighter(140) if accent.lightness() < 140 else QColor(accent)
return accent.darker(135) if accent.lightness() > 150 else QColor(accent)
def positive_color() -> QColor:
"""Цвет позитивного статуса — «Подключено», «актуален», живой пинг.
Берётся от акцента темы, чтобы гамма следовала выбранному цвету;
ошибки и предупреждения остаются красными/оранжевыми (``error_color``,
``warning_color``).
"""
return _positive_for(accent_color(), isDarkTheme())
def positive_pair() -> tuple[str, str]:
"""``(light_hex, dark_hex)`` позитивного цвета для ``setCustomStyleSheet``."""
accent = accent_color()
return _positive_for(accent, False).name(), _positive_for(accent, True).name()
def warning_color() -> QColor:
return _palette_color("warning")
def error_color() -> QColor:
return _palette_color("error")
def info_color() -> QColor:
return _palette_color("info")
def surface_color() -> QColor:
"""Dialog/backdrop surface color for the current theme."""
return _palette_color("surface")
def text_color() -> QColor:
return _palette_color("text")
def text_muted_color() -> QColor:
return _palette_color("text_muted")
def placeholder_text_color() -> QColor:
color = text_color()
color.setAlpha(128)
return color
def graph_down_color() -> QColor:
"""Download line color: follows the accent (D3, AC7).
Light theme — the accent as is; dark theme — a lightened accent, so the
token stays theme-dependent (dark != light for any accent).
"""
accent = accent_color()
return accent.lighter(130) if isDarkTheme() else accent
def graph_up_color() -> QColor:
return _graph_color("graph_up")
def graph_grid_color() -> QColor:
return _graph_color("graph_grid")
def graph_bg_color() -> QColor:
return _graph_color("graph_bg")
def graph_text_color() -> QColor:
return _graph_color("graph_text")
def on_theme_changed(callback: Callable) -> Callable:
"""Invoke *callback* whenever the qfluentwidgets theme changes.
A thin wrapper over ``qconfig.themeChanged.connect`` — bound methods of
QObjects keep Qt's automatic disconnect-on-destroy semantics. Returns the
callback so callers can later ``qconfig.themeChanged.disconnect(cb)``.
"""
qconfig.themeChanged.connect(callback)
return callback
def on_accent_changed(callback: Callable) -> Callable:
"""Invoke *callback* whenever the accent (theme color) changes (AC1).
A thin wrapper over ``qconfig.themeColorChanged.connect``; the callback
must accept one positional argument (a ``QColor``). Returns the callback
so callers can later ``qconfig.themeColorChanged.disconnect(cb)``.
"""
qconfig.themeColorChanged.connect(callback)
return callback
def on_theme_or_accent_changed(callback: Callable) -> Callable:
"""Invoke *callback* on any theme mode or accent change (AC1).
The callback must accept one positional argument (``Theme`` or
``QColor`` depending on the signal).
"""
qconfig.themeChanged.connect(callback)
qconfig.themeColorChanged.connect(callback)
return callback
# ── Theme application (deduplicated) ─────────────────────────────────────
_THEME_MAP = {"dark": Theme.DARK, "light": Theme.LIGHT}
# Last applied (mode, accent) pair; None until the first apply_theme call.
_applied: tuple[str, str] | None = None
def reset_applied_theme() -> None:
"""Forget the deduplication state (used by tests)."""
global _applied
_applied = None
def apply_theme(theme_name: str, accent_color: str, *, force: bool = False) -> bool:
"""Apply theme mode + accent, skipping no-op ``setTheme``/``setThemeColor``.
Returns ``True`` when anything was actually (re)applied.
"""
global _applied
normalized = (theme_name or "system").lower().strip() or "system"
# Normalization makes deduplication case-insensitive (AC4): "#0078d4"
# and "#0078D4" are the same accent.
accent = normalize_accent(accent_color)
previous = _applied
if not force and previous == (normalized, accent):
return False
changed = False
if force or previous is None or previous[0] != normalized:
setTheme(_THEME_MAP.get(normalized, Theme.AUTO))
changed = True
if force or previous is None or previous[1] != accent:
try:
setThemeColor(accent)
changed = True
except Exception:
pass
_applied = (normalized, accent)
return changed
def apply_initial_theme(storage=None) -> tuple[str, str]:
"""Apply the persisted theme before any window/dialog is constructed.
Reads ``AppSettings.theme``/``accent_color`` straight from storage. When
the state file is passphrase-encrypted the settings are unreadable before
the unlock prompt, so defaults are applied now and the real values are
re-applied later by the settings_changed flow (deduplicated).
"""
theme_name, accent = "system", DEFAULT_ACCENT
try:
if storage is None:
from ..profiles.storage import StateStorage
storage = StateStorage()
if not storage.is_encrypted():
state = storage.load()
settings = getattr(state, "settings", None)
if settings is not None:
theme_name = str(getattr(settings, "theme", "") or "system")
accent = str(getattr(settings, "accent_color", "") or DEFAULT_ACCENT)
except Exception:
theme_name, accent = "system", DEFAULT_ACCENT
apply_theme(theme_name, accent, force=True)
return theme_name, accent
# ── System theme listener ("system" mode) ────────────────────────────────
_system_listener = None
LISTENER_STOP_WAIT_MS = 3000
class StoppableSystemThemeListener(SystemThemeListener):
"""``SystemThemeListener``, который завершается по просьбе.
Исходный поток на Windows навсегда блокируется в ``darkdetect.listener``.
При выходе он оставался работать, окно-владелец разрушалось вместе с ним,
и Qt завершал процесс через ``qFatal`` («QThread: Destroyed while thread is
still running»).
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self._watch = ThemeRegistryWatch()
def run(self):
if IS_WINDOWS:
self._watch.run(self._onThemeChanged)
return
super().run()
def stop(self, timeout_ms: int = LISTENER_STOP_WAIT_MS) -> bool:
"""Остановить поток и дождаться его; ``True`` — поток завершён."""
self.requestInterruption()
self._watch.stop()
if not self.wait(timeout_ms):
return False
self._watch.close()
return True
def system_theme_listener():
"""Currently running SystemThemeListener (or None)."""
return _system_listener
def sync_system_theme_listener(theme_name: str, parent=None, listener_factory=None):
"""Start the OS theme listener in "system" mode, stop it otherwise.
``listener_factory`` is injectable for tests (A6/C5); by default the
stoppable variant of the qfluentwidgets ``SystemThemeListener`` is used.
"""
global _system_listener
normalized = (theme_name or "system").lower().strip() or "system"
if normalized == "system":
if _system_listener is None:
factory = listener_factory or StoppableSystemThemeListener
try:
listener = factory(parent)
listener.start()
except Exception:
listener = None
_system_listener = listener
else:
stop_system_theme_listener()
return _system_listener
def stop_system_theme_listener() -> bool:
"""Stop the OS theme listener and wait for its thread to finish.
Returns ``False`` only when the thread is still running afterwards; its
parent window must then not be destroyed.
"""
global _system_listener
if _system_listener is None:
return True
listener, _system_listener = _system_listener, None
return _stop_listener(listener)
def _stop_listener(listener) -> bool:
stopped = True
stop = getattr(listener, "stop", None)
if stop is not None:
try:
stopped = bool(stop())
except Exception:
stopped = False
else:
# Чужой слушатель без stop(): прежний путь qfluentwidgets.
try:
listener.requestInterruption()
except Exception:
pass
try:
listener.terminate()
except Exception:
pass
try:
listener.deleteLater()
except Exception:
pass
return stopped