158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""Tema claro/oscuro siguiendo el esquema de color del sistema.
|
|
|
|
Usa el estilo Fusion (consistente entre plataformas) y aplica una paleta clara u
|
|
oscura según ``QStyleHints.colorScheme()``. Los widgets que no fuerzan colores
|
|
propios (consola de log, fondos) siguen automáticamente esta paleta.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from collections.abc import Callable
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QColor, QPalette
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
# Modos de tema seleccionables por el usuario.
|
|
THEME_SYSTEM = "system"
|
|
THEME_LIGHT = "light"
|
|
THEME_DARK = "dark"
|
|
THEME_MODES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
|
|
|
|
|
|
def _portal_is_dark() -> bool | None:
|
|
"""Consulta el portal XDG (org.freedesktop.appearance color-scheme).
|
|
|
|
Devuelve True (1=dark), False (2=light) o None (0=sin preferencia / no disponible).
|
|
Es la fuente más fiable en Linux cuando Qt no detecta el esquema.
|
|
"""
|
|
try:
|
|
out = subprocess.run(
|
|
[
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.freedesktop.portal.Desktop",
|
|
"--object-path", "/org/freedesktop/portal/desktop",
|
|
"--method", "org.freedesktop.portal.Settings.Read",
|
|
"org.freedesktop.appearance", "color-scheme",
|
|
],
|
|
capture_output=True, text=True, timeout=2,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if out.returncode != 0:
|
|
return None
|
|
if "uint32 1" in out.stdout:
|
|
return True
|
|
if "uint32 2" in out.stdout:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _gsettings_is_dark() -> bool | None:
|
|
"""Respaldo: gsettings color-scheme de GNOME ('prefer-dark')."""
|
|
try:
|
|
out = subprocess.run(
|
|
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
|
capture_output=True, text=True, timeout=2,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if out.returncode != 0:
|
|
return None
|
|
return "dark" in out.stdout.lower()
|
|
|
|
|
|
def system_is_dark(app: QApplication) -> bool:
|
|
"""True si el sistema está en modo oscuro.
|
|
|
|
Prioriza el aviso de Qt; si Qt dice claro (o no soporta la API), consulta el
|
|
portal XDG y gsettings, que en sesiones xcb suelen ser más fiables que Qt.
|
|
"""
|
|
hints = app.styleHints()
|
|
if hasattr(hints, "colorScheme"):
|
|
try:
|
|
if hints.colorScheme() == Qt.ColorScheme.Dark:
|
|
return True
|
|
except Exception: # noqa: BLE001 - APIs viejas/raras: caer al fallback
|
|
pass
|
|
for probe in (_portal_is_dark, _gsettings_is_dark):
|
|
val = probe()
|
|
if val is not None:
|
|
return val
|
|
return app.palette().color(QPalette.Window).lightness() < 128
|
|
|
|
|
|
def _dark_palette() -> QPalette:
|
|
p = QPalette()
|
|
window = QColor(0x2b, 0x2b, 0x2b)
|
|
base = QColor(0x1e, 0x1e, 0x1e)
|
|
alt = QColor(0x35, 0x35, 0x35)
|
|
text = QColor(0xdc, 0xdc, 0xdc)
|
|
disabled = QColor(0x7f, 0x7f, 0x7f)
|
|
highlight = QColor(0x2a, 0x82, 0xda)
|
|
|
|
p.setColor(QPalette.Window, window)
|
|
p.setColor(QPalette.WindowText, text)
|
|
p.setColor(QPalette.Base, base)
|
|
p.setColor(QPalette.AlternateBase, alt)
|
|
p.setColor(QPalette.ToolTipBase, window)
|
|
p.setColor(QPalette.ToolTipText, text)
|
|
p.setColor(QPalette.Text, text)
|
|
p.setColor(QPalette.Button, alt)
|
|
p.setColor(QPalette.ButtonText, text)
|
|
p.setColor(QPalette.BrightText, Qt.red)
|
|
p.setColor(QPalette.Link, highlight)
|
|
p.setColor(QPalette.Highlight, highlight)
|
|
p.setColor(QPalette.HighlightedText, Qt.black)
|
|
p.setColor(QPalette.PlaceholderText, disabled)
|
|
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
|
|
p.setColor(QPalette.Disabled, role, disabled)
|
|
return p
|
|
|
|
|
|
def resolve_is_dark(app: QApplication, mode: str) -> bool:
|
|
"""Decide si pintar oscuro según el modo elegido (system/light/dark)."""
|
|
if mode == THEME_DARK:
|
|
return True
|
|
if mode == THEME_LIGHT:
|
|
return False
|
|
return system_is_dark(app) # THEME_SYSTEM (o valor desconocido)
|
|
|
|
|
|
def _repolish_all(app: QApplication) -> None:
|
|
"""Re-poliza todos los widgets para que adopten la nueva paleta en caliente.
|
|
|
|
``setPalette`` por sí solo no repinta los widgets ya creados: los que tienen
|
|
stylesheet (pills) no re-resuelven ``palette(...)`` y algunos fondos base (la
|
|
consola de log) no se redibujan. unpolish→polish→update fuerza ese refresco.
|
|
"""
|
|
for widget in app.allWidgets():
|
|
style = widget.style()
|
|
style.unpolish(widget)
|
|
style.polish(widget)
|
|
widget.update()
|
|
|
|
|
|
def apply_theme(app: QApplication, mode: str = THEME_SYSTEM) -> None:
|
|
"""Aplica estilo Fusion + paleta clara/oscura según el modo elegido."""
|
|
app.setStyle("Fusion")
|
|
if resolve_is_dark(app, mode):
|
|
app.setPalette(_dark_palette())
|
|
else:
|
|
app.setPalette(app.style().standardPalette())
|
|
_repolish_all(app)
|
|
|
|
|
|
def watch_system_theme(app: QApplication, should_follow: Callable[[], bool]) -> None:
|
|
"""Re-aplica el tema al cambiar el esquema del sistema, solo si seguimos al sistema.
|
|
|
|
``should_follow`` se consulta en cada cambio: devuelve True cuando el modo activo
|
|
es 'system' (si el usuario ha forzado claro/oscuro, ignoramos el cambio del SO).
|
|
"""
|
|
hints = app.styleHints()
|
|
if hasattr(hints, "colorSchemeChanged"):
|
|
hints.colorSchemeChanged.connect(
|
|
lambda _scheme: apply_theme(app, THEME_SYSTEM) if should_follow() else None
|
|
)
|