From e0a93a9c28a7e403213a7dc67ffb946a4fc34363 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 30 May 2026 10:13:32 +0200 Subject: [PATCH] Estil targeta tipus web a les files + tema seleccionable (system/clar/fosc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- games.toml | 17 ++++ jlauncher/__main__.py | 5 +- jlauncher/config.py | 4 + jlauncher/gitops.py | 4 +- jlauncher/metadata.py | 6 +- jlauncher/settings.py | 9 ++ jlauncher/ui/flow_layout.py | 71 ++++++++++++++ jlauncher/ui/game_row.py | 188 ++++++++++++++++++++++++++---------- jlauncher/ui/main_window.py | 38 +++++++- jlauncher/ui/theme.py | 34 +++++-- 10 files changed, 312 insertions(+), 64 deletions(-) create mode 100644 jlauncher/ui/flow_layout.py diff --git a/games.toml b/games.toml index eb6ddf3..3785419 100644 --- a/games.toml +++ b/games.toml @@ -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" diff --git a/jlauncher/__main__.py b/jlauncher/__main__.py index 0908b2e..b27ffe9 100644 --- a/jlauncher/__main__.py +++ b/jlauncher/__main__.py @@ -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: diff --git a/jlauncher/config.py b/jlauncher/config.py index 0c5a4f1..f755b8b 100644 --- a/jlauncher/config.py +++ b/jlauncher/config.py @@ -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", ""), ) ) diff --git a/jlauncher/gitops.py b/jlauncher/gitops.py index 5848ce2..ebce0aa 100644 --- a/jlauncher/gitops.py +++ b/jlauncher/gitops.py @@ -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 diff --git a/jlauncher/metadata.py b/jlauncher/metadata.py index dc95e77..1e85bee 100644 --- a/jlauncher/metadata.py +++ b/jlauncher/metadata.py @@ -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", ""), ) diff --git a/jlauncher/settings.py b/jlauncher/settings.py index 498b20f..7c53025 100644 --- a/jlauncher/settings.py +++ b/jlauncher/settings.py @@ -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)), diff --git a/jlauncher/ui/flow_layout.py b/jlauncher/ui/flow_layout.py new file mode 100644 index 0000000..04fd18a --- /dev/null +++ b/jlauncher/ui/flow_layout.py @@ -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() diff --git a/jlauncher/ui/game_row.py b/jlauncher/ui/game_row.py index a410cd0..3de7e2f 100644 --- a/jlauncher/ui/game_row.py +++ b/jlauncher/ui/game_row.py @@ -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 "" diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py index 193fdcf..f90049f 100644 --- a/jlauncher/ui/main_window.py +++ b/jlauncher/ui/main_window.py @@ -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, diff --git a/jlauncher/ui/theme.py b/jlauncher/ui/theme.py index 26cdbb3..dc287fa 100644 --- a/jlauncher/ui/theme.py +++ b/jlauncher/ui/theme.py @@ -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 + )