9 Commits

Author SHA1 Message Date
JailDesigner be3cb44ae2 Reordena els jocs al toml: CC, CCAE, JDD, AEE, Orni, Projecte 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:51:56 +02:00
JailDesigner 0085c63ace Diàleg «Quant a» al menú Ajuda i bump a 1.0.0
- Nou menú Ajuda amb «Quant a Jail Launcher…» que mostra nom, versió i
  copyright. AboutRole perquè a macOS Qt el mogui al menú de l'aplicació.
- Versió bumpejada a 1.0.0 (jlauncher.__version__ + pyproject), llegida pel
  diàleg per no duplicar-la.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:49:29 +02:00
JailDesigner cebc76b6e3 Consola més baixa (150px) i sense log en entrar al mode esborrar
- CONSOLE_HEIGHT 220 -> 150: la consola desplegada ocupa menys.
- Es treu la línia de log en marcar «Esborra un joc»: cada fila ja mostra
  «Esborra», així que era redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:46:39 +02:00
JailDesigner 38c4f50965 La consola fa créixer la finestra en comptes de menjar espai a la llista
En desplegar/replegar la consola, la finestra creix/encongeix la mateixa alçada
en lockstep amb el panell (i es fixa l'alçada de la consola min=max perquè agafi
exactament aquest espai), de manera que la llista de jocs es manté constant i no
es mou. Si la finestra està maximitzada cau al comportament d'encongir la llista.
També s'allarga el marge de gràcia abans de replegar en mode auto (1.8s -> 4s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:42:28 +02:00
JailDesigner 962e5b054f Consola amb 3 estats (mostra/auto-amaga/amaga), animada i més alta
- Submenú Opcions > Consola: Mostra / Auto-amaga / Amaga, persistit a
  settings.json (console_mode). Es reemplaça el QSplitter per un panell
  col·lapsable amb alçada animada (QPropertyAnimation, easing InOutCubic) i
  més alçada (220px).
- Mode auto: la consola es desplega amb activitat (worker o nova línia de log)
  i es replega sola tras un marge sense activitat.
- Pills robustos al canvi de tema: color de text concret des de la paleta en
  comptes de palette(...) (que Qt cacheja), i pills_box sense fons propi perquè
  no pinti cap banda darrere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:35:37 +02:00
JailDesigner 93efbb06c4 Refresca bé el canvi de tema en calent
setPalette no repolia els widgets ja creats: els stylesheets amb palette(...)
(pills) no es reresolien i la consola de log no repintava el fons. apply_theme
ara fa unpolish→polish→update a tots els widgets, i en canviar de tema es
reconstrueixen les files perquè els pills agafin la paleta nova.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:20:47 +02:00
JailDesigner e0a93a9c28 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>
2026-05-30 10:13:32 +02:00
JailDesigner e9f0098df8 Barra de progrés per a la comprovació d'actualitzacions
- CheckUpdatesWorker emet progress(done, total) per cada joc intentat (èxit o
  error), amb total = jocs descarregats; així la barra arriba al final encara
  que algun repo doni timeout.
- QProgressBar a la status bar (amagada per defecte) que es mostra durant la
  comprovació i s'amaga en acabar o en error. Reutilitzada pel check manual i
  l'automàtic a l'inici.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:03:58 +02:00
JailDesigner c51b7b74ed Comprovació d'updates a l'inici opcional + timeouts de xarxa configurables
- Opció marcable «Comprova actualitzacions a l'inici» al menú; persisteix a
  settings.json i llança la comprovació diferida en obrir la finestra.
- Tolerància a repos offline/inalcanzables: low-speed abort + techo dur de
  temps a les operacions git de xarxa (fetch/clone), evitant cuelgues.
- Timeouts exposats a settings.json (git_fetch_timeout, git_clone_timeout,
  http_timeout, git_stall_limit, git_stall_time) via NetConfig, propagats
  UI -> workers -> gitops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:52:19 +02:00
13 changed files with 749 additions and 128 deletions
+27 -10
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,13 +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"
[[game]]
id = "aee"
name = "Aventures en Egipte"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git"
build_cmd = ""
run_cmd = "make run"
players = "1-2 jugadors"
author = "JailDesigner"
[[game]]
id = "jaildoctors_dilemma"
@@ -39,13 +41,17 @@ 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"
name = "Projecte 2026"
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git"
id = "aee"
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 = "orni_attack"
@@ -53,3 +59,14 @@ 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"
[[game]]
id = "projecte_2026"
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"
+1 -1
View File
@@ -1,3 +1,3 @@
"""jlauncher — lanzador de juegos jailgames."""
__version__ = "0.1.0"
__version__ = "1.0.0"
+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", ""),
)
)
+115 -25
View File
@@ -14,6 +14,7 @@ import subprocess
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from .config import Game
@@ -22,7 +23,26 @@ from .paths import game_dir, metadata_dir, repo_dir
LogFn = Callable[[str], None]
_HTTP_TIMEOUT = 15
# --- Tolerancia a repos offline / inalcanzables ----------------------------
# Dos mecanismos complementarios para que git no se cuelgue:
# 1) low-speed abort: si una transferencia baja de `stall_limit` bytes/s durante
# `stall_time` segundos, git la aborta (transferencias que se estancan a media
# descarga).
# 2) techo duro: timeout en subprocess.run que mata git pase lo que pase (cubre
# cuelgues de conexión TCP/DNS cuando el host está offline, donde aún no fluyen
# bytes y el low-speed no llega a dispararse).
# Los valores son configurables desde settings.json (ver settings.Settings).
@dataclass(frozen=True)
class NetConfig:
fetch_timeout: float = 60 # techo para fetch / comprobar update (operación ligera)
clone_timeout: float = 900 # techo para clone (puede traer un repo grande)
http_timeout: float = 15 # techo para la API de Gitea (urllib)
stall_limit: int = 1000 # bytes/s por debajo de los cuales se considera estancado
stall_time: int = 20 # segundos por debajo del límite -> abortar
DEFAULT_NET = NetConfig()
def _noop(_: str) -> None:
@@ -36,13 +56,33 @@ def _auth_args(token: str) -> list[str]:
return ["-c", f"http.extraHeader=Authorization: token {token}"]
def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla.
def _net_args(net: NetConfig | None) -> list[str]:
"""Args -c para abortar transferencias estancadas (solo afectan al transporte http)."""
if net is None:
return []
return [
"-c",
f"http.lowSpeedLimit={net.stall_limit}",
"-c",
f"http.lowSpeedTime={net.stall_time}",
]
def _run_git(
args: list[str],
cwd: Path | None,
log: LogFn,
token: str = "",
timeout: float | None = None,
net: NetConfig | None = None,
) -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla o agota `timeout`.
Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch
de repos privados) y la redacta en el log para no filtrarla.
de repos privados) y la redacta en el log para no filtrarla. Si se pasa `net`
añade el low-speed abort; `timeout` impone un techo duro (mata git si se cuelga).
"""
cmd = ["git", *_auth_args(token), *args]
cmd = ["git", *_auth_args(token), *_net_args(net), *args]
def emit(line: str) -> None:
log(line.replace(token, "***") if token else line)
@@ -51,13 +91,23 @@ def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") ->
emit("$ " + " ".join(["git", *args]))
# GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido
# en vez de colgarse esperando credenciales (no hay terminal en la GUI).
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
try:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
timeout=timeout,
)
except subprocess.TimeoutExpired:
emit(
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s): "
"el repositori no respon o no és accessible."
)
raise RuntimeError(
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s)"
) from None
if proc.stdout:
emit(proc.stdout.rstrip())
if proc.stderr:
@@ -72,7 +122,13 @@ def is_installed(root: Path, game: Game) -> bool:
return (repo_dir(root, game.id) / ".git").exists()
def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> bool:
def check_update(
root: Path,
game: Game,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> bool:
"""Hace fetch y devuelve True si el clone local está por detrás del remoto.
No modifica el árbol de trabajo: solo cuenta los commits de origin/<rama> que no
@@ -81,7 +137,14 @@ def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") ->
repo = repo_dir(root, game.id)
if not (repo / ".git").exists():
return False
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
_run_git(
["fetch", "origin", "--prune"],
repo,
log,
token=token,
timeout=net.fetch_timeout,
net=net,
)
target = (
load_meta(root, game.id).default_branch
or _detect_origin_head(repo, log)
@@ -106,7 +169,13 @@ def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
shutil.rmtree(target, ignore_errors=True)
def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> GameMeta:
def download(
root: Path,
game: Game,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> GameMeta:
"""Clona (si no existe) o trae el remoto forzado (descartando cambios locales).
Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve.
@@ -114,11 +183,18 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam
repo = repo_dir(root, game.id)
repo.parent.mkdir(parents=True, exist_ok=True)
branch = _fetch_default_branch(game, log, token)
branch = _fetch_default_branch(game, log, token, net)
if (repo / ".git").exists():
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
_run_git(
["fetch", "origin", "--prune"],
repo,
log,
token=token,
timeout=net.fetch_timeout,
net=net,
)
target = branch or _detect_origin_head(repo, log) or "HEAD"
_run_git(["reset", "--hard", f"origin/{target}"], repo, log)
_run_git(["clean", "-fd"], repo, log)
@@ -126,9 +202,16 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam
log(f"Clonant {game.name}")
if repo.exists(): # carpeta a medias sin .git: limpiarla
shutil.rmtree(repo, ignore_errors=True)
_run_git(["clone", game.clone_url, str(repo)], None, log, token=token)
_run_git(
["clone", game.clone_url, str(repo)],
None,
log,
token=token,
timeout=net.clone_timeout,
net=net,
)
return refresh_metadata(root, game, branch, log, token)
return refresh_metadata(root, game, branch, log, token, net)
def refresh_metadata(
@@ -137,16 +220,19 @@ def refresh_metadata(
branch: str | None = None,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> GameMeta:
"""Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea."""
repo = repo_dir(root, game.id)
meta = load_meta(root, game.id)
# Descripción + rama por defecto desde la API de Gitea (best-effort).
api = _fetch_gitea_info(game, log, token)
# 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
@@ -194,7 +280,9 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str:
return out.splitlines()[0] if out else ""
def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
def _fetch_gitea_info(
game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET
) -> dict | None:
if not game.info_url:
return None
headers = {"Accept": "application/json"}
@@ -202,15 +290,17 @@ def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
headers["Authorization"] = f"token {token}"
try:
req = urllib.request.Request(game.info_url, headers=headers)
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
with urllib.request.urlopen(req, timeout=net.http_timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc:
log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}")
return None
def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None:
info = _fetch_gitea_info(game, log, token)
def _fetch_default_branch(
game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET
) -> str | None:
info = _fetch_gitea_info(game, log, token, net)
if info:
return info.get("default_branch")
return None
+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", ""),
)
+32
View File
@@ -10,12 +10,36 @@ 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"
_CONSOLE_MODES = ("show", "auto", "hide")
def _valid_console_mode(value) -> str:
"""Normaliza el modo de consola; cae a 'show' si es desconocido."""
return value if value in _CONSOLE_MODES else "show"
@dataclass
class Settings:
hide_not_downloaded: bool = False
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"
console_mode: str = "show" # consola de log: "show" | "auto" | "hide"
# 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)
http_timeout: int = 15 # techo para la API de Gitea
git_stall_limit: int = 1000 # bytes/s: por debajo se considera transferencia estancada
git_stall_time: int = 20 # segundos estancado antes de abortar
def settings_path() -> Path:
@@ -34,6 +58,14 @@ def load_settings() -> Settings:
hide_not_downloaded=bool(data.get("hide_not_downloaded", False)),
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")),
console_mode=_valid_console_mode(data.get("console_mode", "show")),
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)),
git_stall_limit=int(data.get("git_stall_limit", 1000)),
git_stall_time=int(data.get("git_stall_time", 20)),
)
+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()
+150 -53
View File
@@ -1,35 +1,94 @@
"""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 (
QApplication,
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 _palette_text_color() -> str:
"""Color de text actual de la paleta, com a rgb() concret (no 'palette(text)',
que Qt cacheja als stylesheets i no es refresca en canviar de tema)."""
c = QApplication.palette().color(QPalette.WindowText)
return f"rgb({c.red()}, {c.green()}, {c.blue()})"
def _make_pill(text: str, fg: str | None = None, bg: str = _NEUTRAL_BG,
bold: bool = False) -> QLabel:
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí.
Sense `fg` explícit s'usa el color de text de la paleta vigent (pills neutres).
"""
pill = QLabel(text)
pill.setAttribute(Qt.WA_TransparentForMouseEvents)
weight = "600" if bold else "normal"
pill.setStyleSheet(
f"QLabel {{ background: {bg}; color: {fg or _palette_text_color()}; "
f"border-radius: 9px; padding: 2px 8px; font-size: 11px; "
f"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 +102,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 +111,37 @@ 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)
# Sense fons propi: que no pinti cap banda darrere dels pills.
self.pills_box.setAttribute(Qt.WA_NoSystemBackground, True)
self.pills_box.setAutoFillBackground(False)
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 +151,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 +187,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 +202,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 +276,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 ""
+260 -20
View File
@@ -4,29 +4,39 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QThreadPool, Qt
from PySide6.QtGui import QAction
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QThreadPool, Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import (
QApplication,
QInputDialog,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QScrollArea,
QSplitter,
QVBoxLayout,
QWidget,
)
from .. import gitops
from .. import __version__, 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"
WINDOW_TITLE = f"© 2026 {APP_NAME} — JailDesigner"
CONSOLE_HEIGHT = 150 # alçada de la consola desplegada (px)
CONSOLE_ANIM_MS = 220 # durada de l'animació de desplegar/replegar
CONSOLE_IDLE_MS = 4000 # marge sense activitat abans de replegar en mode auto
CONSOLE_SHOW = "show"
CONSOLE_AUTO = "auto"
CONSOLE_HIDE = "hide"
class MainWindow(QMainWindow):
def __init__(self, config: Config, root: Path, parent=None) -> None:
@@ -45,9 +55,19 @@ 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)
central = QWidget()
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
# --- Lista de juegos con scroll ---
list_container = QWidget()
@@ -65,18 +85,26 @@ class MainWindow(QMainWindow):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(list_container)
splitter.addWidget(scroll)
root_layout.addWidget(scroll, stretch=1)
# --- Panel de log ---
# --- Panel de log (consola colapsable amb alçada animada) ---
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setMaximumBlockCount(5000)
self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;")
splitter.addWidget(self.log_view)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
self.log_view.setMinimumHeight(0)
root_layout.addWidget(self.log_view)
self.setCentralWidget(splitter)
self.setCentralWidget(central)
self._setup_console()
# Barra de progrés (cantonada de la status bar) per a la comprovació d'updates;
# amagada fins que arrenca una comprovació.
self.progress = QProgressBar()
self.progress.setMaximumWidth(220)
self.progress.setFormat("Comprovant %v/%m")
self.progress.hide()
self.statusBar().addPermanentWidget(self.progress)
# Estado persistido: marcas de update + filtro de ocultar no descargados.
for game_id in self.settings.updates_pending:
@@ -84,6 +112,105 @@ class MainWindow(QMainWindow):
self.rows[game_id].set_update_available(True)
self._apply_filter()
# Comprobación automática de updates al iniciar (si está activada). Se difiere
# con un timer 0 para que la ventana se muestre antes de lanzar el worker.
if self.settings.check_updates_on_start:
QTimer.singleShot(0, self._check_updates)
def _net_config(self) -> gitops.NetConfig:
"""Construye la config de red/timeouts a partir de las preferencias guardadas."""
s = self.settings
return gitops.NetConfig(
fetch_timeout=s.git_fetch_timeout,
clone_timeout=s.git_clone_timeout,
http_timeout=s.http_timeout,
stall_limit=s.git_stall_limit,
stall_time=s.git_stall_time,
)
# --------------------------------------------------------------- consola
def _setup_console(self) -> None:
"""Prepara la animació d'alçada i l'estat inicial segons console_mode."""
self._console_open = False
self._console_anim_start = 0 # alçada de consola en arrencar l'animació
self._console_win_start = 0 # alçada de finestra en arrencar l'animació
self._console_grow_window = True
self._console_anim = QPropertyAnimation(self.log_view, b"maximumHeight", self)
self._console_anim.setDuration(CONSOLE_ANIM_MS)
self._console_anim.setEasingCurve(QEasingCurve.InOutCubic)
self._console_anim.valueChanged.connect(self._on_console_anim_value)
self._console_anim.finished.connect(self._on_console_anim_done)
# Timer que replega la consola en mode auto després d'un marge sense activitat.
self._console_idle_timer = QTimer(self)
self._console_idle_timer.setSingleShot(True)
self._console_idle_timer.setInterval(CONSOLE_IDLE_MS)
self._console_idle_timer.timeout.connect(self._on_console_idle)
# Estat inicial sense animació: oberta només en mode "show".
self.log_view.setMaximumHeight(0)
self.log_view.hide()
if self.settings.console_mode == CONSOLE_SHOW:
self._set_console_open(True, animated=False)
def _set_console_open(self, open_: bool, animated: bool = True) -> None:
"""Desplega/replega la consola fent créixer o encongir la finestra (perquè la
consola guanyi espai en comptes de menjar-ne a la llista)."""
if open_ == self._console_open:
return
self._console_open = open_
start = self.log_view.maximumHeight()
target = CONSOLE_HEIGHT if open_ else 0
# Si la finestra està maximitzada/pantalla completa no la podem fer créixer:
# caiem al comportament d'encongir la llista.
grow = not (self.isMaximized() or self.isFullScreen())
if open_:
self.log_view.show() # visible abans d'animar l'obertura
self._console_anim.stop()
if not animated:
if grow:
self.resize(self.width(), self.height() + (target - start))
self.log_view.setMinimumHeight(target)
self.log_view.setMaximumHeight(target)
if not open_:
self.log_view.hide()
return
self._console_grow_window = grow
self._console_anim_start = start
self._console_win_start = self.height()
self._console_anim.setStartValue(start)
self._console_anim.setEndValue(target)
self._console_anim.start()
def _on_console_anim_value(self, value: int) -> None:
"""A cada pas: fixem l'alçada de la consola a `value` (min=max, perquè agafi
exactament aquest espai i no el cedeixi a la llista) i fem créixer/encongir la
finestra el mateix, així la llista (finestra consola) es manté constant."""
self.log_view.setMinimumHeight(value)
if self._console_grow_window:
delta = value - self._console_anim_start
self.resize(self.width(), self._console_win_start + delta)
def _on_console_anim_done(self) -> None:
if not self._console_open:
self.log_view.hide() # replegada del tot: treure-la del layout
def _on_console_idle(self) -> None:
if self.settings.console_mode == CONSOLE_AUTO and not self._workers:
self._set_console_open(False)
def _console_activity_started(self) -> None:
"""Hi ha activitat (worker o log): en mode auto, desplega i atura el timer."""
if self.settings.console_mode == CONSOLE_AUTO:
self._console_idle_timer.stop()
self._set_console_open(True)
def _console_activity_maybe_ended(self) -> None:
"""Si no queden workers actius, en mode auto arrenca el compte enrere per replegar."""
if self.settings.console_mode == CONSOLE_AUTO and not self._workers:
self._console_idle_timer.start()
# --------------------------------------------------------------- menú
def _build_menu(self) -> None:
@@ -98,6 +225,17 @@ class MainWindow(QMainWindow):
self.action_check.triggered.connect(self._check_updates)
menu.addAction(self.action_check)
self.action_check_on_start = QAction(
"Comprova actualitzacions a l'inici", self, checkable=True
)
self.action_check_on_start.setChecked(self.settings.check_updates_on_start)
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)
self._build_console_menu(menu)
menu.addSeparator()
self.action_delete = QAction("Esborra un joc", self, checkable=True)
self.action_delete.toggled.connect(self._set_delete_mode)
@@ -108,6 +246,83 @@ class MainWindow(QMainWindow):
self.action_token.triggered.connect(self._configure_token)
menu.addAction(self.action_token)
self._build_help_menu()
def _build_help_menu(self) -> None:
"""Menú Ajuda amb el «Quant a…». A macOS, AboutRole el mou al menú de l'app."""
help_menu = self.menuBar().addMenu("Ajuda")
self.action_about = QAction(f"Quant a {APP_NAME}", self)
self.action_about.setMenuRole(QAction.MenuRole.AboutRole)
self.action_about.triggered.connect(self._show_about)
help_menu.addAction(self.action_about)
def _show_about(self) -> None:
QMessageBox.about(
self,
f"Quant a {APP_NAME}",
f"<b>{APP_NAME}</b><br>"
f"Versió {__version__}<br><br>"
"© 2026 JailDesigner",
)
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 _build_console_menu(self, parent_menu) -> None:
"""Submenú Consola amb tres estats exclusius: Mostra / Auto-amaga / Amaga."""
submenu = parent_menu.addMenu("Consola")
group = QActionGroup(self)
group.setExclusive(True)
options = [
("Mostra", CONSOLE_SHOW),
("Auto-amaga", CONSOLE_AUTO),
("Amaga", CONSOLE_HIDE),
]
for label, mode in options:
action = QAction(label, self, checkable=True)
action.setChecked(self.settings.console_mode == mode)
action.triggered.connect(
lambda _checked, m=mode: self._on_console_mode_selected(m)
)
group.addAction(action)
submenu.addAction(action)
def _on_console_mode_selected(self, mode: str) -> None:
self.settings.console_mode = mode
save_settings(self.settings)
self._console_idle_timer.stop()
if mode == CONSOLE_SHOW:
self._set_console_open(True)
elif mode == CONSOLE_HIDE:
self._set_console_open(False)
else: # auto: oberta si hi ha activitat, si no replegada
self._set_console_open(bool(self._workers))
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)
# Reconstruir las filas: los pills usan `palette(...)` en su stylesheet, que
# Qt cachea; recrearlos los re-resuelve contra la paleta ya aplicada.
for row in self.rows.values():
row.refresh()
def _configure_token(self) -> None:
token, ok = QInputDialog.getText(
self,
@@ -129,6 +344,10 @@ class MainWindow(QMainWindow):
save_settings(self.settings)
self._apply_filter()
def _on_toggle_check_on_start(self, checked: bool) -> None:
self.settings.check_updates_on_start = checked
save_settings(self.settings)
def _apply_filter(self) -> None:
hide = self.action_hide.isChecked()
for row in self.rows.values():
@@ -139,14 +358,24 @@ class MainWindow(QMainWindow):
def _check_updates(self) -> None:
self.action_check.setEnabled(False)
self._log("=== Comprovant actualitzacions ===")
worker = CheckUpdatesWorker(self.root, self.config.games, self.settings.gitea_token)
worker = CheckUpdatesWorker(
self.root, self.config.games, self.settings.gitea_token, self._net_config()
)
worker.signals.log.connect(self._log)
worker.signals.result.connect(self._mark_update)
worker.signals.progress.connect(self._check_progress)
worker.signals.finished.connect(self._check_done)
worker.signals.error.connect(self._check_error)
self._track(worker)
self.pool.start(worker)
def _check_progress(self, done: int, total: int) -> None:
if total <= 0:
return # res descarregat a comprovar: no mostrem barra
self.progress.setMaximum(total)
self.progress.setValue(done)
self.progress.show()
def _mark_update(self, game_id: str, has_update: bool) -> None:
row = self.rows.get(game_id)
if row is not None:
@@ -158,24 +387,38 @@ class MainWindow(QMainWindow):
def _check_done(self, _payload) -> None:
self.action_check.setEnabled(True)
self.progress.hide()
self._log("=== Comprovació d'actualitzacions acabada ===")
def _check_error(self, msg: str) -> None:
self.action_check.setEnabled(True)
self.progress.hide()
self._log(f"!!! Error comprovant actualitzacions: {msg}")
# --------------------------------------------------------------- helpers
def _log(self, text: str) -> None:
self.log_view.appendPlainText(text)
# En mode auto, qualsevol línia desplega la consola; si no hi ha cap worker
# actiu (p.ex. un missatge solt), arrenca el compte enrere per replegar-la.
if self.settings.console_mode == CONSOLE_AUTO:
self._console_activity_started()
if not self._workers:
self._console_idle_timer.start()
def _track(self, worker) -> None:
"""Retiene el worker hasta que emite finished/error, evitando que el GC
se lleve su objeto de señales antes de entregar la señal en cola."""
worker.setAutoDelete(False)
self._workers.add(worker)
worker.signals.finished.connect(lambda *_: self._workers.discard(worker))
worker.signals.error.connect(lambda *_: self._workers.discard(worker))
self._console_activity_started()
def _done(*_):
self._workers.discard(worker)
self._console_activity_maybe_ended()
worker.signals.finished.connect(_done)
worker.signals.error.connect(_done)
# --------------------------------------------------------------- accions
@@ -183,11 +426,6 @@ class MainWindow(QMainWindow):
self._delete_mode = on
for row in self.rows.values():
row.set_delete_mode(on)
if on:
self._log(
"Mode esborrar: tria un joc descarregat per eliminar-lo "
"(o desmarca «Esborra un joc» per cancel·lar)."
)
def _on_activate(self, game: Game) -> None:
"""Clic sobre la fila. En mode esborrar elimina; si no, descarrega/actualitza o juga."""
@@ -208,7 +446,9 @@ class MainWindow(QMainWindow):
row.set_busy(True, "Descarregant…")
self._log(f"=== Descàrrega: {game.name} ===")
worker = DownloadWorker(self.root, game, self.settings.gitea_token)
worker = DownloadWorker(
self.root, game, self.settings.gitea_token, self._net_config()
)
worker.signals.log.connect(self._log)
worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g))
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
+43 -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,47 @@ 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 _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 system_is_dark(app):
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) -> 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
)
+37 -9
View File
@@ -20,22 +20,34 @@ class _Signals(QObject):
finished = Signal(object) # payload según el worker (GameMeta o int exit code)
error = Signal(str) # mensaje de error
result = Signal(str, bool) # (game_id, has_update) — CheckUpdatesWorker
progress = Signal(int, int) # (done, total) — CheckUpdatesWorker
class DownloadWorker(QRunnable):
"""Clona o actualiza (forzado) y refresca la metadata de un juego."""
def __init__(self, root: Path, game: Game, token: str = "") -> None:
def __init__(
self,
root: Path,
game: Game,
token: str = "",
net: gitops.NetConfig = gitops.DEFAULT_NET,
) -> None:
super().__init__()
self.root = root
self.game = game
self.token = token
self.net = net
self.signals = _Signals()
def run(self) -> None: # noqa: D401 - API de QRunnable
try:
meta: GameMeta = gitops.download(
self.root, self.game, log=self.signals.log.emit, token=self.token
self.root,
self.game,
log=self.signals.log.emit,
token=self.token,
net=self.net,
)
except Exception as exc: # noqa: BLE001 - reportar a la UI
self.signals.error.emit(str(exc))
@@ -67,26 +79,42 @@ class CheckUpdatesWorker(QRunnable):
Emite `result(game_id, has_update)` por cada juego instalado y `finished` al final.
"""
def __init__(self, root: Path, games: list[Game], token: str = "") -> None:
def __init__(
self,
root: Path,
games: list[Game],
token: str = "",
net: gitops.NetConfig = gitops.DEFAULT_NET,
) -> None:
super().__init__()
self.root = root
self.games = games
self.token = token
self.net = net
self.signals = _Signals()
def run(self) -> None: # noqa: D401 - API de QRunnable
try:
for game in self.games:
if not gitops.is_installed(self.root, game):
continue
# Total = juegos descargados (los no instalados no se comprueban). Emitimos
# progress por cada juego *intentado*, éxito o error, para que la barra
# llegue siempre al final aunque algún repo dé timeout.
installed = [g for g in self.games if gitops.is_installed(self.root, g)]
total = len(installed)
self.signals.progress.emit(0, total)
for done, game in enumerate(installed, start=1):
try:
has_update = gitops.check_update(
self.root, game, log=self.signals.log.emit, token=self.token
self.root,
game,
log=self.signals.log.emit,
token=self.token,
net=self.net,
)
except Exception as exc: # noqa: BLE001 - no abortar el resto
self.signals.log.emit(f"check {game.id}: {exc}")
continue
self.signals.result.emit(game.id, has_update)
else:
self.signals.result.emit(game.id, has_update)
self.signals.progress.emit(done, total)
except Exception as exc: # noqa: BLE001 - reportar a la UI
self.signals.error.emit(str(exc))
return
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "jlauncher"
version = "0.1.0"
version = "1.0.0"
description = "Lanzador de juegos jailgames: clona, compila y ejecuta repos Gitea"
requires-python = ">=3.11"
dependencies = [