Compare commits
9 Commits
90b7bb5fb1
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| be3cb44ae2 | |||
| 0085c63ace | |||
| cebc76b6e3 | |||
| 38c4f50965 | |||
| 962e5b054f | |||
| 93efbb06c4 | |||
| e0a93a9c28 | |||
| e9f0098df8 | |||
| c51b7b74ed |
+27
-10
@@ -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,3 +1,3 @@
|
||||
"""jlauncher — lanzador de juegos jailgames."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+108
-18
@@ -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).
|
||||
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
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
+152
-55
@@ -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()
|
||||
elif not installed:
|
||||
self._set_action("Descarrega", "#4a90d9")
|
||||
elif self._update_available:
|
||||
self._set_action("Actualitza", "#e0a030")
|
||||
if installed:
|
||||
self._set_action_text("Esborra", "#d9534f")
|
||||
else:
|
||||
self._set_action("Juga", "#6fae6f")
|
||||
self.action_label.clear() # no es pot esborrar el que no està
|
||||
elif not installed:
|
||||
self._set_action_text("Descarrega", "#4a90d9")
|
||||
elif self._update_available:
|
||||
self._set_action_text("Actualitza", "#e0a030")
|
||||
else:
|
||||
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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
+36
-8
@@ -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
|
||||
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
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user