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:
+17
@@ -9,6 +9,11 @@
|
|||||||
# version_cmd (opcional) comando que imprime la versión. Default: "git describe --tags --always"
|
# 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
|
# 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"
|
# 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"
|
data_dir = "jlauncher_data"
|
||||||
|
|
||||||
@@ -18,6 +23,8 @@ name = "Coffee Crisis"
|
|||||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis.git"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
[[game]]
|
[[game]]
|
||||||
id = "coffee_crisis_arcade_edition"
|
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"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis_arcade_edition.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
[[game]]
|
[[game]]
|
||||||
id = "aee"
|
id = "aee"
|
||||||
@@ -32,6 +41,8 @@ name = "Aventures en Egipte"
|
|||||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
[[game]]
|
[[game]]
|
||||||
id = "jaildoctors_dilemma"
|
id = "jaildoctors_dilemma"
|
||||||
@@ -39,6 +50,8 @@ name = "JailDoctor's Dilemma"
|
|||||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors_dilemma.git"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors_dilemma.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
[[game]]
|
[[game]]
|
||||||
id = "projecte_2026"
|
id = "projecte_2026"
|
||||||
@@ -46,6 +59,8 @@ name = "Projecte 2026"
|
|||||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
[[game]]
|
[[game]]
|
||||||
id = "orni_attack"
|
id = "orni_attack"
|
||||||
@@ -53,3 +68,5 @@ name = "Orni Attack"
|
|||||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/orni_attack.git"
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/orni_attack.git"
|
||||||
build_cmd = ""
|
build_cmd = ""
|
||||||
run_cmd = "make run"
|
run_cmd = "make run"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ from PySide6.QtWidgets import QApplication, QMessageBox
|
|||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .paths import config_file, data_root
|
from .paths import config_file, data_root
|
||||||
from .ui.main_window import MainWindow
|
from .ui.main_window import MainWindow
|
||||||
from .ui.theme import apply_theme, watch_system_theme
|
from .ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("jlauncher")
|
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)
|
apply_theme(app)
|
||||||
watch_system_theme(app)
|
|
||||||
|
|
||||||
cfg_path = config_file()
|
cfg_path = config_file()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Game:
|
|||||||
version_cmd: str = DEFAULT_VERSION_CMD
|
version_cmd: str = DEFAULT_VERSION_CMD
|
||||||
info_url: str = ""
|
info_url: str = ""
|
||||||
icon_rel: str = DEFAULT_ICON_REL
|
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:
|
def __post_init__(self) -> None:
|
||||||
if not self.info_url:
|
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,
|
version_cmd=entry.get("version_cmd") or DEFAULT_VERSION_CMD,
|
||||||
info_url=entry.get("info_url", ""),
|
info_url=entry.get("info_url", ""),
|
||||||
icon_rel=entry.get("icon_rel") or DEFAULT_ICON_REL,
|
icon_rel=entry.get("icon_rel") or DEFAULT_ICON_REL,
|
||||||
|
players=entry.get("players", ""),
|
||||||
|
author=entry.get("author", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -226,11 +226,13 @@ def refresh_metadata(
|
|||||||
repo = repo_dir(root, game.id)
|
repo = repo_dir(root, game.id)
|
||||||
meta = load_meta(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)
|
api = _fetch_gitea_info(game, log, token, net)
|
||||||
if api is not None:
|
if api is not None:
|
||||||
meta.description = api.get("description", meta.description) or meta.description
|
meta.description = api.get("description", meta.description) or meta.description
|
||||||
meta.default_branch = api.get("default_branch", meta.default_branch)
|
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:
|
elif branch:
|
||||||
meta.default_branch = branch
|
meta.default_branch = branch
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import metadata_dir
|
from .paths import metadata_dir
|
||||||
@@ -18,6 +18,8 @@ class GameMeta:
|
|||||||
version: str = ""
|
version: str = ""
|
||||||
default_branch: str = "main"
|
default_branch: str = "main"
|
||||||
updated_at: str = "" # ISO-8601; lo rellena el worker tras un update
|
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:
|
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", ""),
|
version=data.get("version", ""),
|
||||||
default_branch=data.get("default_branch", "main"),
|
default_branch=data.get("default_branch", "main"),
|
||||||
updated_at=data.get("updated_at", ""),
|
updated_at=data.get("updated_at", ""),
|
||||||
|
topics=list(data.get("topics", [])),
|
||||||
|
created_at=data.get("created_at", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ from .paths import base_dir
|
|||||||
|
|
||||||
SETTINGS_NAME = "settings.json"
|
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
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
@@ -17,6 +24,7 @@ class Settings:
|
|||||||
updates_pending: list[str] = field(default_factory=list) # ids con update pendiente
|
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)
|
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
|
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).
|
# Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s).
|
||||||
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
|
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
|
||||||
git_clone_timeout: int = 900 # techo para clone (repo grande)
|
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", [])),
|
updates_pending=list(data.get("updates_pending", [])),
|
||||||
gitea_token=str(data.get("gitea_token", "")),
|
gitea_token=str(data.get("gitea_token", "")),
|
||||||
check_updates_on_start=bool(data.get("check_updates_on_start", False)),
|
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_fetch_timeout=int(data.get("git_fetch_timeout", 60)),
|
||||||
git_clone_timeout=int(data.get("git_clone_timeout", 900)),
|
git_clone_timeout=int(data.get("git_clone_timeout", 900)),
|
||||||
http_timeout=int(data.get("http_timeout", 15)),
|
http_timeout=int(data.get("http_timeout", 15)),
|
||||||
|
|||||||
@@ -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
@@ -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:
|
Un clic fa l'acció principal segons l'estat:
|
||||||
- no descarregat → descarrega
|
- no descarregat → descarrega
|
||||||
- update pendent → actualitza
|
- update pendent → actualitza
|
||||||
- descarregat i al dia → juga
|
- descarregat i al dia → juga
|
||||||
Un indicador d'icona a la dreta (passiu) mostra què farà el clic. L'esborrat es
|
A la dreta un indicador de text (passiu) mostra què farà el clic. Sota el títol i la
|
||||||
gestiona per fora (mètode a definir); aquest widget només exposa la senyal.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import QRectF, Qt, Signal
|
||||||
from PySide6.QtGui import QPalette, QPixmap
|
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QSizePolicy,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..config import Game
|
from ..config import Game
|
||||||
from ..metadata import GameMeta, icon_path, load_meta
|
from ..metadata import GameMeta, icon_path, load_meta
|
||||||
from ..paths import repo_dir
|
from ..paths import repo_dir
|
||||||
|
from .flow_layout import FlowLayout
|
||||||
|
|
||||||
ICON_SIZE = 64
|
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):
|
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)
|
activated = Signal(object) # Game — clic sobre la fila (acció principal)
|
||||||
delete_requested = Signal(object) # Game — esborrar la descàrrega local
|
delete_requested = Signal(object) # Game — esborrar la descàrrega local
|
||||||
@@ -43,7 +90,6 @@ class GameRow(QFrame):
|
|||||||
self._delete_mode = False
|
self._delete_mode = False
|
||||||
self.setObjectName("gameRow")
|
self.setObjectName("gameRow")
|
||||||
self.setFrameShape(QFrame.StyledPanel)
|
self.setFrameShape(QFrame.StyledPanel)
|
||||||
# La fila és un botó: cursor de mà i ressaltat en passar el ratolí.
|
|
||||||
self.setCursor(Qt.PointingHandCursor)
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
self.setStyleSheet(
|
self.setStyleSheet(
|
||||||
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
|
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
|
||||||
@@ -53,26 +99,34 @@ class GameRow(QFrame):
|
|||||||
layout.setContentsMargins(10, 8, 10, 8)
|
layout.setContentsMargins(10, 8, 10, 8)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(12)
|
||||||
|
|
||||||
# --- Icona del joc ---
|
# --- Icona del joc (arrodonida) ---
|
||||||
self.icon_label = QLabel()
|
self.icon_label = QLabel()
|
||||||
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
|
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
|
||||||
self.icon_label.setAlignment(Qt.AlignCenter)
|
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 = QVBoxLayout()
|
||||||
text_box.setSpacing(2)
|
text_box.setSpacing(4)
|
||||||
|
|
||||||
self.name_label = QLabel(game.name)
|
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 = QLabel("")
|
||||||
self.desc_label.setWordWrap(True)
|
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.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.name_label)
|
||||||
text_box.addWidget(self.desc_label)
|
text_box.addWidget(self.desc_label)
|
||||||
text_box.addWidget(self.status_label)
|
text_box.addWidget(self.pills_box)
|
||||||
text_box.addStretch(1)
|
text_box.addStretch(1)
|
||||||
layout.addLayout(text_box, stretch=1)
|
layout.addLayout(text_box, stretch=1)
|
||||||
|
|
||||||
@@ -82,14 +136,7 @@ class GameRow(QFrame):
|
|||||||
self.action_label.setMinimumWidth(96)
|
self.action_label.setMinimumWidth(96)
|
||||||
layout.addWidget(self.action_label)
|
layout.addWidget(self.action_label)
|
||||||
|
|
||||||
# Els fills no intercepten el ratolí: tot clic arriba a la fila.
|
for child in (self.name_label, self.desc_label, self.action_label):
|
||||||
for child in (
|
|
||||||
self.icon_label,
|
|
||||||
self.name_label,
|
|
||||||
self.desc_label,
|
|
||||||
self.status_label,
|
|
||||||
self.action_label,
|
|
||||||
):
|
|
||||||
child.setAttribute(Qt.WA_TransparentForMouseEvents)
|
child.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -125,11 +172,12 @@ class GameRow(QFrame):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def refresh(self) -> None:
|
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)
|
meta = load_meta(self.root, self.game.id)
|
||||||
self._set_icon()
|
self._set_icon()
|
||||||
self.desc_label.setText(meta.description or "(sense descripció encara)")
|
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:
|
def _set_icon(self) -> None:
|
||||||
path = icon_path(self.root, self.game.id)
|
path = icon_path(self.root, self.game.id)
|
||||||
@@ -139,44 +187,70 @@ class GameRow(QFrame):
|
|||||||
self.icon_label.setStyleSheet("font-size: 32px;")
|
self.icon_label.setStyleSheet("font-size: 32px;")
|
||||||
else:
|
else:
|
||||||
self.icon_label.setStyleSheet("")
|
self.icon_label.setStyleSheet("")
|
||||||
self.icon_label.setPixmap(
|
self.icon_label.setPixmap(_rounded_pixmap(pixmap, ICON_SIZE, ICON_RADIUS))
|
||||||
pixmap.scaled(
|
|
||||||
ICON_SIZE,
|
|
||||||
ICON_SIZE,
|
|
||||||
Qt.KeepAspectRatio,
|
|
||||||
Qt.SmoothTransformation,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
installed = self.is_installed()
|
||||||
|
|
||||||
# Text d'estat (independent del mode esborrar).
|
# 1) Estat (pill amb color).
|
||||||
if not installed:
|
if not installed:
|
||||||
self.status_label.setText("No descarregat")
|
fg, bg = _STATUS_COLORS["none"]
|
||||||
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
|
self.pills_layout.addWidget(_make_pill("No descarregat", fg, bg, bold=True))
|
||||||
elif self._update_available:
|
elif self._update_available:
|
||||||
self.status_label.setText("⬆ Actualització disponible")
|
fg, bg = _STATUS_COLORS["update"]
|
||||||
self.status_label.setStyleSheet(
|
self.pills_layout.addWidget(
|
||||||
"color: #e0a030; font-weight: bold; font-size: 11px;"
|
_make_pill("⬆ Actualització", fg, bg, bold=True)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ver = f" {meta.version}" if meta.version else ""
|
fg, bg = _STATUS_COLORS["ok"]
|
||||||
self.status_label.setText(f"Descarregat{ver}")
|
self.pills_layout.addWidget(_make_pill("✓ Descarregat", fg, bg, bold=True))
|
||||||
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
|
|
||||||
|
|
||||||
# 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:
|
if self._delete_mode:
|
||||||
# Només es pot esborrar el que està descarregat.
|
if installed:
|
||||||
self._set_action("Esborra", "#d9534f") if installed else self.action_label.clear()
|
self._set_action_text("Esborra", "#d9534f")
|
||||||
|
else:
|
||||||
|
self.action_label.clear() # no es pot esborrar el que no està
|
||||||
elif not installed:
|
elif not installed:
|
||||||
self._set_action("Descarrega", "#4a90d9")
|
self._set_action_text("Descarrega", "#4a90d9")
|
||||||
elif self._update_available:
|
elif self._update_available:
|
||||||
self._set_action("Actualitza", "#e0a030")
|
self._set_action_text("Actualitza", "#e0a030")
|
||||||
else:
|
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.setText(text)
|
||||||
self.action_label.setStyleSheet(
|
self.action_label.setStyleSheet(
|
||||||
f"color: {color}; font-weight: bold; font-size: 13px;"
|
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:
|
def set_busy(self, busy: bool, message: str = "") -> None:
|
||||||
self._busy = busy
|
self._busy = busy
|
||||||
if busy and message:
|
if busy and message:
|
||||||
self.status_label.setText(message)
|
self._clear_pills()
|
||||||
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
|
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 ""
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QThreadPool, Qt, QTimer
|
from PySide6.QtCore import QThreadPool, Qt, QTimer
|
||||||
from PySide6.QtGui import QAction
|
from PySide6.QtGui import QAction, QActionGroup
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
@@ -23,6 +24,7 @@ from .. import gitops
|
|||||||
from ..config import Config, Game
|
from ..config import Config, Game
|
||||||
from ..settings import load_settings, save_settings
|
from ..settings import load_settings, save_settings
|
||||||
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
|
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
|
||||||
|
from . import theme
|
||||||
from .game_row import GameRow
|
from .game_row import GameRow
|
||||||
|
|
||||||
APP_NAME = "Jail Launcher"
|
APP_NAME = "Jail Launcher"
|
||||||
@@ -46,6 +48,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.setWindowTitle(WINDOW_TITLE)
|
self.setWindowTitle(WINDOW_TITLE)
|
||||||
self.resize(720, 640)
|
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()
|
self._build_menu()
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Vertical)
|
splitter = QSplitter(Qt.Vertical)
|
||||||
@@ -130,6 +139,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
|
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
|
||||||
menu.addAction(self.action_check_on_start)
|
menu.addAction(self.action_check_on_start)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
self._build_theme_menu(menu)
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
self.action_delete = QAction("Esborra un joc", self, checkable=True)
|
self.action_delete = QAction("Esborra un joc", self, checkable=True)
|
||||||
self.action_delete.toggled.connect(self._set_delete_mode)
|
self.action_delete.toggled.connect(self._set_delete_mode)
|
||||||
@@ -140,6 +152,30 @@ class MainWindow(QMainWindow):
|
|||||||
self.action_token.triggered.connect(self._configure_token)
|
self.action_token.triggered.connect(self._configure_token)
|
||||||
menu.addAction(self.action_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:
|
def _configure_token(self) -> None:
|
||||||
token, ok = QInputDialog.getText(
|
token, ok = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+28
-6
@@ -8,11 +8,18 @@ propios (consola de log, fondos) siguen automáticamente esta paleta.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QColor, QPalette
|
from PySide6.QtGui import QColor, QPalette
|
||||||
from PySide6.QtWidgets import QApplication
|
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:
|
def _portal_is_dark() -> bool | None:
|
||||||
"""Consulta el portal XDG (org.freedesktop.appearance color-scheme).
|
"""Consulta el portal XDG (org.freedesktop.appearance color-scheme).
|
||||||
@@ -104,17 +111,32 @@ def _dark_palette() -> QPalette:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def apply_theme(app: QApplication) -> None:
|
def resolve_is_dark(app: QApplication, mode: str) -> bool:
|
||||||
"""Aplica estilo Fusion + paleta acorde al esquema del sistema."""
|
"""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")
|
app.setStyle("Fusion")
|
||||||
if system_is_dark(app):
|
if resolve_is_dark(app, mode):
|
||||||
app.setPalette(_dark_palette())
|
app.setPalette(_dark_palette())
|
||||||
else:
|
else:
|
||||||
app.setPalette(app.style().standardPalette())
|
app.setPalette(app.style().standardPalette())
|
||||||
|
|
||||||
|
|
||||||
def watch_system_theme(app: QApplication) -> None:
|
def watch_system_theme(app: QApplication, should_follow: Callable[[], bool]) -> None:
|
||||||
"""Re-aplica el tema cuando el sistema cambia entre claro/oscuro en caliente."""
|
"""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()
|
hints = app.styleHints()
|
||||||
if hasattr(hints, "colorSchemeChanged"):
|
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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user