Estil targeta tipus web a les files + tema seleccionable (system/clar/fosc)

UI:
- Files amb estil de targeta: icona arrodonida, títol gran, subtítol atenuat i
  'pills' amb estat, versió, data de llançament, jugadors, autor i topics. Els
  pills envolten amb un FlowLayout nou quan no caben.
- Submenú Opcions > Tema amb Sistema/Clar/Fosc; persisteix a settings.json
  (theme) i s'aplica a l'instant. El watcher del SO només actua en mode Sistema.

Dades:
- GameMeta guarda topics i created_at, llegits de la resposta de Gitea que ja
  demanàvem (gratis, auto-sincronitzats).
- games.toml: camps opcionals players i author per joc (la resta surt de Gitea).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:13:32 +02:00
parent e9f0098df8
commit e0a93a9c28
10 changed files with 312 additions and 64 deletions
+17
View File
@@ -9,6 +9,11 @@
# version_cmd (opcional) comando que imprime la versión. Default: "git describe --tags --always"
# info_url (opcional) API de Gitea del repo. Default: derivada de clone_url
# icon_rel (opcional) ruta del icono dentro del repo. Default: "release/icons/icon.png"
# players (opcional) texto del pill de jugadores, p.ej. "1-2 jugadors" (Gitea no lo da)
# author (opcional) texto del pill de autor, p.ej. "JailDesigner"
#
# Otros pills (topics, descripción, fecha de lanzamiento, versión) salen
# automáticamente de Gitea / git; no hace falta escribirlos aquí.
data_dir = "jlauncher_data"
@@ -18,6 +23,8 @@ name = "Coffee Crisis"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis.git"
build_cmd = ""
run_cmd = "make run"
players = "1-2 jugadors"
author = "JailDesigner"
[[game]]
id = "coffee_crisis_arcade_edition"
@@ -25,6 +32,8 @@ name = "Coffee Crisis Arcade Edition"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis_arcade_edition.git"
build_cmd = ""
run_cmd = "make run"
players = "1-2 jugadors"
author = "JailDesigner"
[[game]]
id = "aee"
@@ -32,6 +41,8 @@ name = "Aventures en Egipte"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git"
build_cmd = ""
run_cmd = "make run"
players = "1 jugador"
author = "JailDesigner"
[[game]]
id = "jaildoctors_dilemma"
@@ -39,6 +50,8 @@ name = "JailDoctor's Dilemma"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors_dilemma.git"
build_cmd = ""
run_cmd = "make run"
players = "1 jugador"
author = "JailDesigner"
[[game]]
id = "projecte_2026"
@@ -46,6 +59,8 @@ name = "Projecte 2026"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git"
build_cmd = ""
run_cmd = "make run"
players = "1 jugador"
author = "JailDesigner"
[[game]]
id = "orni_attack"
@@ -53,3 +68,5 @@ name = "Orni Attack"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/orni_attack.git"
build_cmd = ""
run_cmd = "make run"
players = "1-2 jugadors"
author = "JailDesigner"
+3 -2
View File
@@ -9,14 +9,15 @@ from PySide6.QtWidgets import QApplication, QMessageBox
from .config import load_config
from .paths import config_file, data_root
from .ui.main_window import MainWindow
from .ui.theme import apply_theme, watch_system_theme
from .ui.theme import apply_theme
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("jlauncher")
# Tema del sistema para el posible diálogo de error previo a la ventana;
# MainWindow re-aplica el modo guardado (system/light/dark) y vigila los cambios.
apply_theme(app)
watch_system_theme(app)
cfg_path = config_file()
try:
+4
View File
@@ -21,6 +21,8 @@ class Game:
version_cmd: str = DEFAULT_VERSION_CMD
info_url: str = ""
icon_rel: str = DEFAULT_ICON_REL
players: str = "" # texto manual para el pill de jugadores (Gitea no lo tiene)
author: str = "" # texto manual para el pill de autor
def __post_init__(self) -> None:
if not self.info_url:
@@ -70,6 +72,8 @@ def load_config(path: Path) -> Config:
version_cmd=entry.get("version_cmd") or DEFAULT_VERSION_CMD,
info_url=entry.get("info_url", ""),
icon_rel=entry.get("icon_rel") or DEFAULT_ICON_REL,
players=entry.get("players", ""),
author=entry.get("author", ""),
)
)
+3 -1
View File
@@ -226,11 +226,13 @@ def refresh_metadata(
repo = repo_dir(root, game.id)
meta = load_meta(root, game.id)
# Descripción + rama por defecto desde la API de Gitea (best-effort).
# Descripción + rama + topics + fecha de creación desde la API de Gitea (best-effort).
api = _fetch_gitea_info(game, log, token, net)
if api is not None:
meta.description = api.get("description", meta.description) or meta.description
meta.default_branch = api.get("default_branch", meta.default_branch)
meta.topics = list(api.get("topics") or [])
meta.created_at = api.get("created_at", meta.created_at) or meta.created_at
elif branch:
meta.default_branch = branch
+5 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from pathlib import Path
from .paths import metadata_dir
@@ -18,6 +18,8 @@ class GameMeta:
version: str = ""
default_branch: str = "main"
updated_at: str = "" # ISO-8601; lo rellena el worker tras un update
topics: list[str] = field(default_factory=list) # tags de Gitea (controller, sdl3…)
created_at: str = "" # ISO-8601 de creación del repo en Gitea (≈ lanzamiento)
def info_path(root: Path, game_id: str) -> Path:
@@ -42,6 +44,8 @@ def load_meta(root: Path, game_id: str) -> GameMeta:
version=data.get("version", ""),
default_branch=data.get("default_branch", "main"),
updated_at=data.get("updated_at", ""),
topics=list(data.get("topics", [])),
created_at=data.get("created_at", ""),
)
+9
View File
@@ -10,6 +10,13 @@ from .paths import base_dir
SETTINGS_NAME = "settings.json"
_THEMES = ("system", "light", "dark")
def _valid_theme(value) -> str:
"""Normaliza el tema a uno válido; cae a 'system' si es desconocido."""
return value if value in _THEMES else "system"
@dataclass
class Settings:
@@ -17,6 +24,7 @@ class Settings:
updates_pending: list[str] = field(default_factory=list) # ids con update pendiente
gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona)
check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar
theme: str = "system" # tema de la UI: "system" | "light" | "dark"
# Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s).
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
git_clone_timeout: int = 900 # techo para clone (repo grande)
@@ -42,6 +50,7 @@ def load_settings() -> Settings:
updates_pending=list(data.get("updates_pending", [])),
gitea_token=str(data.get("gitea_token", "")),
check_updates_on_start=bool(data.get("check_updates_on_start", False)),
theme=_valid_theme(data.get("theme", "system")),
git_fetch_timeout=int(data.get("git_fetch_timeout", 60)),
git_clone_timeout=int(data.get("git_clone_timeout", 900)),
http_timeout=int(data.get("http_timeout", 15)),
+71
View File
@@ -0,0 +1,71 @@
"""Layout que coloca los widgets en fila y salta de línea cuando no caben.
Port mínimo del ejemplo FlowLayout de Qt: se usa para los 'pills' de cada fila,
que pueden ser muchos (topics) y deben envolver sin desbordar horizontalmente.
"""
from __future__ import annotations
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QWidget
class FlowLayout(QLayout):
def __init__(self, parent: QWidget | None = None, spacing: int = 6) -> None:
super().__init__(parent)
self._items: list[QLayoutItem] = []
self._spacing = spacing
self.setContentsMargins(0, 0, 0, 0)
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - API Qt
self._items.append(item)
def count(self) -> int:
return len(self._items)
def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
return self._items.pop(index) if 0 <= index < len(self._items) else None
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - API Qt
return Qt.Orientation(0)
def hasHeightForWidth(self) -> bool: # noqa: N802 - API Qt
return True
def heightForWidth(self, width: int) -> int: # noqa: N802 - API Qt
return self._layout(QRect(0, 0, width, 0), apply=False)
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - API Qt
super().setGeometry(rect)
self._layout(rect, apply=True)
def sizeHint(self) -> QSize: # noqa: N802 - API Qt
return self.minimumSize()
def minimumSize(self) -> QSize: # noqa: N802 - API Qt
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
m: QMargins = self.contentsMargins()
return size + QSize(m.left() + m.right(), m.top() + m.bottom())
def _layout(self, rect: QRect, apply: bool) -> int:
m: QMargins = self.contentsMargins()
eff = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())
x, y, line_h = eff.x(), eff.y(), 0
for item in self._items:
hint = item.sizeHint()
next_x = x + hint.width() + self._spacing
if next_x - self._spacing > eff.right() and line_h > 0:
x = eff.x()
y = y + line_h + self._spacing
next_x = x + hint.width() + self._spacing
line_h = 0
if apply:
item.setGeometry(QRect(QPoint(x, y), hint))
x = next_x
line_h = max(line_h, hint.height())
return y + line_h - rect.y() + m.bottom()
+135 -53
View File
@@ -1,35 +1,82 @@
"""Fila de la llista: la fila sencera actua com un botó.
"""Fila de la llista: la fila sencera actua com un botó, amb estil de targeta.
Un clic fa l'acció principal segons l'estat:
- no descarregat → descarrega
- update pendent → actualitza
- descarregat i al dia → juga
Un indicador d'icona a la dreta (passiu) mostra què farà el clic. L'esborrat es
gestiona per fora (mètode a definir); aquest widget només exposa la senyal.
A la dreta un indicador de text (passiu) mostra què farà el clic. Sota el títol i la
descripció es pinten 'pills' amb la metadata: estat, versió, data, jugadors, autor i
els topics de Gitea. L'esborrat es gestiona per fora; aquest widget només l'exposa.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtCore import QRectF, Qt, Signal
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..config import Game
from ..metadata import GameMeta, icon_path, load_meta
from ..paths import repo_dir
from .flow_layout import FlowLayout
ICON_SIZE = 64
ICON_RADIUS = 14
# Colors d'estat (text + fons translúcid del pill).
_STATUS_COLORS = {
"none": ("#cc8855", "rgba(204, 136, 85, 0.18)"), # no descarregat
"update": ("#e0a030", "rgba(224, 160, 48, 0.20)"), # actualització disponible
"ok": ("#6fae6f", "rgba(111, 174, 111, 0.18)"), # descarregat i al dia
}
_NEUTRAL_BG = "rgba(127, 127, 127, 0.18)" # pills informatius (segueix clar/fosc)
def _rounded_pixmap(src: QPixmap, size: int, radius: int) -> QPixmap:
"""Escala omplint el quadrat, retalla al centre i arrodoneix les cantonades."""
scaled = src.scaled(
size, size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation
)
x = max(0, (scaled.width() - size) // 2)
y = max(0, (scaled.height() - size) // 2)
cropped = scaled.copy(x, y, size, size)
out = QPixmap(size, size)
out.fill(Qt.transparent)
painter = QPainter(out)
painter.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addRoundedRect(QRectF(0, 0, size, size), radius, radius)
painter.setClipPath(path)
painter.drawPixmap(0, 0, cropped)
painter.end()
return out
def _make_pill(text: str, fg: str = "palette(text)", bg: str = _NEUTRAL_BG,
bold: bool = False) -> QLabel:
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí."""
pill = QLabel(text)
pill.setAttribute(Qt.WA_TransparentForMouseEvents)
weight = "600" if bold else "normal"
pill.setStyleSheet(
f"QLabel {{ background: {bg}; color: {fg}; border-radius: 9px; "
f"padding: 2px 8px; font-size: 11px; font-weight: {weight}; }}"
)
return pill
class GameRow(QFrame):
"""Fila clicable. Emet `activated` en clic i `delete_requested` per al futur esborrat."""
"""Fila clicable. Emet `activated` en clic i `delete_requested` per a l'esborrat."""
activated = Signal(object) # Game — clic sobre la fila (acció principal)
delete_requested = Signal(object) # Game — esborrar la descàrrega local
@@ -43,7 +90,6 @@ class GameRow(QFrame):
self._delete_mode = False
self.setObjectName("gameRow")
self.setFrameShape(QFrame.StyledPanel)
# La fila és un botó: cursor de mà i ressaltat en passar el ratolí.
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
@@ -53,26 +99,34 @@ class GameRow(QFrame):
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(12)
# --- Icona del joc ---
# --- Icona del joc (arrodonida) ---
self.icon_label = QLabel()
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
self.icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.icon_label)
layout.addWidget(self.icon_label, alignment=Qt.AlignTop)
# --- Text (nom + descripció + estat) ---
# --- Text: títol + descripció + pills (centrat verticalment) ---
text_box = QVBoxLayout()
text_box.setSpacing(2)
text_box.setSpacing(4)
self.name_label = QLabel(game.name)
self.name_label.setStyleSheet("font-weight: bold; font-size: 14px;")
self.name_label.setStyleSheet("font-weight: bold; font-size: 18px;")
self.desc_label = QLabel("")
self.desc_label.setWordWrap(True)
# Text atenuat que segueix la paleta (clar/fosc) en lloc d'un gris fix.
self.desc_label.setForegroundRole(QPalette.PlaceholderText)
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
# Contenidor de pills amb FlowLayout (envolten si no caben).
self.pills_box = QWidget()
self.pills_layout = FlowLayout(self.pills_box, spacing=6)
self.pills_box.setAttribute(Qt.WA_TransparentForMouseEvents)
sp = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
sp.setHeightForWidth(True)
self.pills_box.setSizePolicy(sp)
text_box.addStretch(1)
text_box.addWidget(self.name_label)
text_box.addWidget(self.desc_label)
text_box.addWidget(self.status_label)
text_box.addWidget(self.pills_box)
text_box.addStretch(1)
layout.addLayout(text_box, stretch=1)
@@ -82,14 +136,7 @@ class GameRow(QFrame):
self.action_label.setMinimumWidth(96)
layout.addWidget(self.action_label)
# Els fills no intercepten el ratolí: tot clic arriba a la fila.
for child in (
self.icon_label,
self.name_label,
self.desc_label,
self.status_label,
self.action_label,
):
for child in (self.name_label, self.desc_label, self.action_label):
child.setAttribute(Qt.WA_TransparentForMouseEvents)
self.refresh()
@@ -125,11 +172,12 @@ class GameRow(QFrame):
self.refresh()
def refresh(self) -> None:
"""Recarrega icona, descripció i estat des de la cache local."""
"""Recarrega icona, descripció, pills i estat des de la cache local."""
meta = load_meta(self.root, self.game.id)
self._set_icon()
self.desc_label.setText(meta.description or "(sense descripció encara)")
self._set_status(meta)
self._rebuild_pills(meta)
self._set_action(meta)
def _set_icon(self) -> None:
path = icon_path(self.root, self.game.id)
@@ -139,44 +187,70 @@ class GameRow(QFrame):
self.icon_label.setStyleSheet("font-size: 32px;")
else:
self.icon_label.setStyleSheet("")
self.icon_label.setPixmap(
pixmap.scaled(
ICON_SIZE,
ICON_SIZE,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
)
self.icon_label.setPixmap(_rounded_pixmap(pixmap, ICON_SIZE, ICON_RADIUS))
def _set_status(self, meta: GameMeta) -> None:
# ----------------------------------------------------------------- pills
def _clear_pills(self) -> None:
while self.pills_layout.count():
item = self.pills_layout.takeAt(0)
w = item.widget() if item else None
if w is not None:
w.deleteLater()
def _rebuild_pills(self, meta: GameMeta) -> None:
self._clear_pills()
installed = self.is_installed()
# Text d'estat (independent del mode esborrar).
# 1) Estat (pill amb color).
if not installed:
self.status_label.setText("No descarregat")
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
fg, bg = _STATUS_COLORS["none"]
self.pills_layout.addWidget(_make_pill("No descarregat", fg, bg, bold=True))
elif self._update_available:
self.status_label.setText("⬆ Actualització disponible")
self.status_label.setStyleSheet(
"color: #e0a030; font-weight: bold; font-size: 11px;"
fg, bg = _STATUS_COLORS["update"]
self.pills_layout.addWidget(
_make_pill("⬆ Actualització", fg, bg, bold=True)
)
else:
ver = f" {meta.version}" if meta.version else ""
self.status_label.setText(f"Descarregat{ver}")
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
fg, bg = _STATUS_COLORS["ok"]
self.pills_layout.addWidget(_make_pill("✓ Descarregat", fg, bg, bold=True))
# Text d'acció (què fa el clic).
# 2) Versió (només si està descarregat i la coneixem).
if installed and meta.version:
self.pills_layout.addWidget(_make_pill(meta.version))
# 3) Data de llançament (creació del repo a Gitea).
released = _year_month_day(meta.created_at)
if released:
self.pills_layout.addWidget(_make_pill(released))
# 4) Jugadors i autor (manuals, de games.toml).
if self.game.players:
self.pills_layout.addWidget(_make_pill(self.game.players))
if self.game.author:
self.pills_layout.addWidget(_make_pill(self.game.author))
# 5) Topics de Gitea.
for topic in meta.topics:
self.pills_layout.addWidget(_make_pill(topic))
# --------------------------------------------------------------- acció
def _set_action(self, meta: GameMeta) -> None:
installed = self.is_installed()
if self._delete_mode:
# Només es pot esborrar el que està descarregat.
self._set_action("Esborra", "#d9534f") if installed else self.action_label.clear()
if installed:
self._set_action_text("Esborra", "#d9534f")
else:
self.action_label.clear() # no es pot esborrar el que no està
elif not installed:
self._set_action("Descarrega", "#4a90d9")
self._set_action_text("Descarrega", "#4a90d9")
elif self._update_available:
self._set_action("Actualitza", "#e0a030")
self._set_action_text("Actualitza", "#e0a030")
else:
self._set_action("Juga", "#6fae6f")
self._set_action_text("Juga", "#6fae6f")
def _set_action(self, text: str, color: str) -> None:
def _set_action_text(self, text: str, color: str) -> None:
self.action_label.setText(text)
self.action_label.setStyleSheet(
f"color: {color}; font-weight: bold; font-size: 13px;"
@@ -187,5 +261,13 @@ class GameRow(QFrame):
def set_busy(self, busy: bool, message: str = "") -> None:
self._busy = busy
if busy and message:
self.status_label.setText(message)
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
self._clear_pills()
self.pills_layout.addWidget(_make_pill(message, "#d0d060"))
def _year_month_day(iso: str) -> str:
"""De un ISO-8601 (2026-04-05T20:26:09+02:00) treu '2026-04-05'; '' si no és vàlid."""
if not iso or len(iso) < 10:
return ""
date = iso[:10]
return date if date[4] == "-" and date[7] == "-" else ""
+37 -1
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QThreadPool, Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import (
QApplication,
QInputDialog,
QLineEdit,
QMainWindow,
@@ -23,6 +24,7 @@ from .. import gitops
from ..config import Config, Game
from ..settings import load_settings, save_settings
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
from . import theme
from .game_row import GameRow
APP_NAME = "Jail Launcher"
@@ -46,6 +48,13 @@ class MainWindow(QMainWindow):
self.setWindowTitle(WINDOW_TITLE)
self.resize(720, 640)
# Aplica el tema guardado (system/light/dark) i vigila els canvis del SO
# només quan estem en mode 'system'.
app = QApplication.instance()
if app is not None:
theme.apply_theme(app, self.settings.theme)
theme.watch_system_theme(app, lambda: self.settings.theme == theme.THEME_SYSTEM)
self._build_menu()
splitter = QSplitter(Qt.Vertical)
@@ -130,6 +139,9 @@ class MainWindow(QMainWindow):
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
menu.addAction(self.action_check_on_start)
menu.addSeparator()
self._build_theme_menu(menu)
menu.addSeparator()
self.action_delete = QAction("Esborra un joc", self, checkable=True)
self.action_delete.toggled.connect(self._set_delete_mode)
@@ -140,6 +152,30 @@ class MainWindow(QMainWindow):
self.action_token.triggered.connect(self._configure_token)
menu.addAction(self.action_token)
def _build_theme_menu(self, parent_menu) -> None:
"""Submenú Tema amb tres opcions exclusives: Sistema / Clar / Fosc."""
submenu = parent_menu.addMenu("Tema")
group = QActionGroup(self)
group.setExclusive(True)
options = [
("Sistema", theme.THEME_SYSTEM),
("Clar", theme.THEME_LIGHT),
("Fosc", theme.THEME_DARK),
]
for label, mode in options:
action = QAction(label, self, checkable=True)
action.setChecked(self.settings.theme == mode)
action.triggered.connect(lambda _checked, m=mode: self._on_theme_selected(m))
group.addAction(action)
submenu.addAction(action)
def _on_theme_selected(self, mode: str) -> None:
self.settings.theme = mode
save_settings(self.settings)
app = QApplication.instance()
if app is not None:
theme.apply_theme(app, mode)
def _configure_token(self) -> None:
token, ok = QInputDialog.getText(
self,
+28 -6
View File
@@ -8,11 +8,18 @@ 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).
@@ -104,17 +111,32 @@ def _dark_palette() -> QPalette:
return p
def apply_theme(app: QApplication) -> None:
"""Aplica estilo Fusion + paleta acorde al esquema del sistema."""
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 apply_theme(app: QApplication, mode: str = THEME_SYSTEM) -> None:
"""Aplica estilo Fusion + paleta clara/oscura según el modo elegido."""
app.setStyle("Fusion")
if system_is_dark(app):
if resolve_is_dark(app, mode):
app.setPalette(_dark_palette())
else:
app.setPalette(app.style().standardPalette())
def watch_system_theme(app: QApplication) -> None:
"""Re-aplica el tema cuando el sistema cambia entre claro/oscuro en caliente."""
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))
hints.colorSchemeChanged.connect(
lambda _scheme: apply_theme(app, THEME_SYSTEM) if should_follow() else None
)