Files
jail-launcher/jail_launcher/ui/theme.py
T

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
)