Interfície en català, botó Esborra i botons d'icona segons l'estat

This commit is contained in:
2026-05-29 21:55:34 +02:00
parent 235a3966d2
commit 694d67f11e
6 changed files with 117 additions and 45 deletions
+9 -3
View File
@@ -30,16 +30,22 @@ jlauncher_data/
icon.png # copiado desde repo/release/icons/icon.png icon.png # copiado desde repo/release/icons/icon.png
``` ```
## Botones ## Botones (interfaz en catalán)
- **Download**: si no existe el clone, hace `git clone`. Si existe, trae el remoto - **Descarrega**: si no existe el clone, hace `git clone`. Si existe, trae el remoto
**forzado** (`git fetch` + `git reset --hard origin/<rama>` + `git clean -fd`), **forzado** (`git fetch` + `git reset --hard origin/<rama>` + `git clean -fd`),
descartando cualquier cambio local. Después refresca la metadata: descartando cualquier cambio local. Después refresca la metadata:
- descripción desde la API de Gitea (`/api/v1/repos/<org>/<repo>`), - descripción desde la API de Gitea (`/api/v1/repos/<org>/<repo>`),
- versión ejecutando `version_cmd` (por defecto `git describe --tags --always`), - versión ejecutando `version_cmd` (por defecto `git describe --tags --always`),
- icono desde `release/icons/icon.png`. - icono desde `release/icons/icon.png`.
- **Run**: si hay `build_cmd`, compila primero; luego ejecuta `run_cmd`. Para estos - **Juga**: si hay `build_cmd`, compila primero; luego ejecuta `run_cmd`. Para estos
juegos basta `run_cmd = "make run"` (compila y ejecuta), con `build_cmd` vacío. juegos basta `run_cmd = "make run"` (compila y ejecuta), con `build_cmd` vacío.
- **Esborra**: elimina la descarga local (carpeta del juego en `jlauncher_data/`),
sin quitar el juego del `games.toml`.
Menú **Opcions**: *Amaga els jocs no descarregats* (filtro persistente) y
*Comprova actualitzacions* (marca los juegos descargados con commits pendientes).
Las preferencias se guardan en `settings.json` junto al ejecutable.
Las operaciones corren en segundo plano (QThreadPool); el log aparece en el panel inferior. Las operaciones corren en segundo plano (QThreadPool); el log aparece en el panel inferior.
+2 -2
View File
@@ -24,8 +24,8 @@ def main() -> int:
except Exception as exc: # noqa: BLE001 - mostrar cualquier error de carga al usuario except Exception as exc: # noqa: BLE001 - mostrar cualquier error de carga al usuario
QMessageBox.critical( QMessageBox.critical(
None, None,
"Error cargando games.toml", "Error carregant games.toml",
f"No se pudo leer la configuración en:\n{cfg_path}\n\n{exc}", f"No s'ha pogut llegir la configuració a:\n{cfg_path}\n\n{exc}",
) )
return 1 return 1
+18 -8
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from .config import Game from .config import Game
from .metadata import GameMeta, icon_path, load_meta, save_meta from .metadata import GameMeta, icon_path, load_meta, save_meta
from .paths import metadata_dir, repo_dir from .paths import game_dir, metadata_dir, repo_dir
LogFn = Callable[[str], None] LogFn = Callable[[str], None]
@@ -76,6 +76,16 @@ def check_update(root: Path, game: Game, log: LogFn = _noop) -> bool:
return int(behind or "0") > 0 return int(behind or "0") > 0
def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
"""Esborra la descàrrega local del joc (clon + metadata), sense tocar el TOML."""
target = game_dir(root, game.id)
if not target.exists():
log(f"{game.name}: no hi ha res a esborrar")
return
log(f"Esborrant la descàrrega local de {game.name}")
shutil.rmtree(target, ignore_errors=True)
def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta: def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta:
"""Clona (si no existe) o trae el remoto forzado (descartando cambios locales). """Clona (si no existe) o trae el remoto forzado (descartando cambios locales).
@@ -87,13 +97,13 @@ def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta:
branch = _fetch_default_branch(game, log) branch = _fetch_default_branch(game, log)
if (repo / ".git").exists(): if (repo / ".git").exists():
log(f"Actualizando {game.name} (forzado, descartando cambios locales)…") log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
_run_git(["fetch", "origin", "--prune"], repo, log) _run_git(["fetch", "origin", "--prune"], repo, log)
target = branch or _detect_origin_head(repo, log) or "HEAD" target = branch or _detect_origin_head(repo, log) or "HEAD"
_run_git(["reset", "--hard", f"origin/{target}"], repo, log) _run_git(["reset", "--hard", f"origin/{target}"], repo, log)
_run_git(["clean", "-fd"], repo, log) _run_git(["clean", "-fd"], repo, log)
else: else:
log(f"Clonando {game.name}") log(f"Clonant {game.name}")
if repo.exists(): # carpeta a medias sin .git: limpiarla if repo.exists(): # carpeta a medias sin .git: limpiarla
shutil.rmtree(repo, ignore_errors=True) shutil.rmtree(repo, ignore_errors=True)
_run_git(["clone", game.clone_url, str(repo)], None, log) _run_git(["clone", game.clone_url, str(repo)], None, log)
@@ -131,14 +141,14 @@ def refresh_metadata(
def _copy_icon(root: Path, game: Game, repo: Path, log: LogFn) -> None: def _copy_icon(root: Path, game: Game, repo: Path, log: LogFn) -> None:
src = repo / game.icon_rel src = repo / game.icon_rel
if not src.exists(): if not src.exists():
log(f"(sin icono en {game.icon_rel})") log(f"(sense icona a {game.icon_rel})")
return return
metadata_dir(root, game.id).mkdir(parents=True, exist_ok=True) metadata_dir(root, game.id).mkdir(parents=True, exist_ok=True)
try: try:
shutil.copyfile(src, icon_path(root, game.id)) shutil.copyfile(src, icon_path(root, game.id))
log(f"Icono actualizado desde {game.icon_rel}") log(f"Icona actualitzada des de {game.icon_rel}")
except OSError as exc: except OSError as exc:
log(f"No se pudo copiar el icono: {exc}") log(f"No s'ha pogut copiar la icona: {exc}")
def _read_version(game: Game, repo: Path, log: LogFn) -> str: def _read_version(game: Game, repo: Path, log: LogFn) -> str:
@@ -154,7 +164,7 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str:
timeout=20, timeout=20,
) )
except (OSError, subprocess.SubprocessError) as exc: except (OSError, subprocess.SubprocessError) as exc:
log(f"version_cmd falló: {exc}") log(f"version_cmd ha fallat: {exc}")
return "" return ""
out = (proc.stdout or "").strip() out = (proc.stdout or "").strip()
return out.splitlines()[0] if out else "" return out.splitlines()[0] if out else ""
@@ -168,7 +178,7 @@ def _fetch_gitea_info(game: Game, log: LogFn) -> dict | None:
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc: except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc:
log(f"No se pudo leer la info de Gitea ({game.info_url}): {exc}") log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}")
return None return None
+4 -4
View File
@@ -43,15 +43,15 @@ def run_game(root: Path, game: Game, log: LogFn = _noop) -> int:
repo = repo_dir(root, game.id) repo = repo_dir(root, game.id)
if not (repo / ".git").exists(): if not (repo / ".git").exists():
raise FileNotFoundError( raise FileNotFoundError(
f"{game.name} no está descargado. Pulsa Download primero." f"{game.name} no està descarregat. Prem Descarrega primer."
) )
if game.build_cmd.strip(): if game.build_cmd.strip():
log(f"Compilando {game.name}") log(f"Compilant {game.name}")
code = _stream(game.build_cmd, repo, log) code = _stream(game.build_cmd, repo, log)
if code != 0: if code != 0:
log(f"Compilación falló (código {code}). No se ejecuta.") log(f"La compilació ha fallat (codi {code}). No s'executa.")
return code return code
log(f"Ejecutando {game.name}") log(f"Executant {game.name}")
return _stream(game.run_cmd, repo, log) return _stream(game.run_cmd, repo, log)
+45 -15
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QPalette, QPixmap from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton, QPushButton,
QSizePolicy, QStyle,
QVBoxLayout, QVBoxLayout,
) )
@@ -27,6 +27,7 @@ class GameRow(QFrame):
download_requested = Signal(object) # Game download_requested = Signal(object) # Game
run_requested = Signal(object) # Game run_requested = Signal(object) # Game
delete_requested = Signal(object) # Game
def __init__(self, game: Game, root: Path, parent=None) -> None: def __init__(self, game: Game, root: Path, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -63,17 +64,31 @@ class GameRow(QFrame):
text_box.addStretch(1) text_box.addStretch(1)
layout.addLayout(text_box, stretch=1) layout.addLayout(text_box, stretch=1)
# --- Botones --- # --- Botons (icones compactes amb tooltip; visibilitat segons l'estat) ---
self.download_btn = QPushButton("Download") style = self.style()
self.run_btn = QPushButton("Run") self._icon_download = style.standardIcon(QStyle.SP_ArrowDown)
for btn in (self.download_btn, self.run_btn): self._icon_update = style.standardIcon(QStyle.SP_BrowserReload)
btn.setMinimumWidth(96)
btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.download_btn = QPushButton()
self.run_btn = QPushButton()
self.run_btn.setIcon(style.standardIcon(QStyle.SP_MediaPlay))
self.run_btn.setToolTip("Juga")
self.delete_btn = QPushButton()
self.delete_btn.setIcon(style.standardIcon(QStyle.SP_TrashIcon))
self.delete_btn.setToolTip("Esborra")
for btn in (self.download_btn, self.run_btn, self.delete_btn):
btn.setFixedSize(36, 30)
btn.setIconSize(QSize(18, 18))
self.download_btn.clicked.connect(lambda: self.download_requested.emit(self.game)) self.download_btn.clicked.connect(lambda: self.download_requested.emit(self.game))
self.run_btn.clicked.connect(lambda: self.run_requested.emit(self.game)) self.run_btn.clicked.connect(lambda: self.run_requested.emit(self.game))
btn_box = QVBoxLayout() self.delete_btn.clicked.connect(lambda: self.delete_requested.emit(self.game))
btn_box = QHBoxLayout()
btn_box.setSpacing(4)
btn_box.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
btn_box.addWidget(self.download_btn) btn_box.addWidget(self.download_btn)
btn_box.addWidget(self.run_btn) btn_box.addWidget(self.run_btn)
btn_box.addWidget(self.delete_btn)
layout.addLayout(btn_box) layout.addLayout(btn_box)
self.refresh() self.refresh()
@@ -92,7 +107,7 @@ class GameRow(QFrame):
"""Recarga icono, descripción y estado desde la cache local.""" """Recarga icono, descripción y estado desde la cache local."""
meta = load_meta(self.root, self.game.id) meta = load_meta(self.root, self.game.id)
self._set_icon() self._set_icon()
self.desc_label.setText(meta.description or "(sin descripción todavía)") self.desc_label.setText(meta.description or "(sense descripció encara)")
self._set_status(meta) self._set_status(meta)
def _set_icon(self) -> None: def _set_icon(self) -> None:
@@ -113,18 +128,32 @@ class GameRow(QFrame):
) )
def _set_status(self, meta: GameMeta) -> None: def _set_status(self, meta: GameMeta) -> None:
if not self.is_installed(): installed = self.is_installed()
self.status_label.setText("No instalado")
# Visibilitat dels botons segons l'estat:
# - Juga / Esborra: només si està descarregat.
# - Descarrega: si no està descarregat o hi ha update pendent.
self.run_btn.setVisible(installed)
self.delete_btn.setVisible(installed)
self.download_btn.setVisible((not installed) or self._update_available)
if installed and self._update_available:
self.download_btn.setIcon(self._icon_update)
self.download_btn.setToolTip("Actualitza")
else:
self.download_btn.setIcon(self._icon_download)
self.download_btn.setToolTip("Descarrega")
if not installed:
self.status_label.setText("No descarregat")
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;") self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
self.run_btn.setEnabled(True) # se permite, avisará si no está
elif self._update_available: elif self._update_available:
self.status_label.setText("⬆ Actualización disponible") self.status_label.setText("⬆ Actualització disponible")
self.status_label.setStyleSheet( self.status_label.setStyleSheet(
"color: #e0a030; font-weight: bold; font-size: 11px;" "color: #e0a030; font-weight: bold; font-size: 11px;"
) )
else: else:
ver = f" {meta.version}" if meta.version else "" ver = f" {meta.version}" if meta.version else ""
self.status_label.setText(f"Instalado{ver}") self.status_label.setText(f"Descarregat{ver}")
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;") self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
# --------------------------------------------------------------- busy UI # --------------------------------------------------------------- busy UI
@@ -132,6 +161,7 @@ class GameRow(QFrame):
def set_busy(self, busy: bool, message: str = "") -> None: def set_busy(self, busy: bool, message: str = "") -> None:
self.download_btn.setEnabled(not busy) self.download_btn.setEnabled(not busy)
self.run_btn.setEnabled(not busy) self.run_btn.setEnabled(not busy)
self.delete_btn.setEnabled(not busy)
if busy and message: if busy and message:
self.status_label.setText(message) self.status_label.setText(message)
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;") self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
+39 -13
View File
@@ -8,6 +8,7 @@ from PySide6.QtCore import QThreadPool, Qt
from PySide6.QtGui import QAction from PySide6.QtGui import QAction
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QMainWindow,
QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QScrollArea, QScrollArea,
QSplitter, QSplitter,
@@ -15,6 +16,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from .. import gitops
from ..config import Config, Game from ..config import Config, Game
from ..settings import load_settings, save_settings from ..settings import load_settings, save_settings
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
@@ -53,6 +55,7 @@ class MainWindow(QMainWindow):
row = GameRow(game, root) row = GameRow(game, root)
row.download_requested.connect(self._on_download) row.download_requested.connect(self._on_download)
row.run_requested.connect(self._on_run) row.run_requested.connect(self._on_run)
row.delete_requested.connect(self._on_delete)
self.rows[game.id] = row self.rows[game.id] = row
list_layout.addWidget(row) list_layout.addWidget(row)
list_layout.addStretch(1) list_layout.addStretch(1)
@@ -82,14 +85,14 @@ class MainWindow(QMainWindow):
# --------------------------------------------------------------- menú # --------------------------------------------------------------- menú
def _build_menu(self) -> None: def _build_menu(self) -> None:
menu = self.menuBar().addMenu("Opciones") menu = self.menuBar().addMenu("Opcions")
self.action_hide = QAction("Ocultar juegos no descargados", self, checkable=True) self.action_hide = QAction("Amaga els jocs no descarregats", self, checkable=True)
self.action_hide.setChecked(self.settings.hide_not_downloaded) self.action_hide.setChecked(self.settings.hide_not_downloaded)
self.action_hide.toggled.connect(self._on_toggle_hide) self.action_hide.toggled.connect(self._on_toggle_hide)
menu.addAction(self.action_hide) menu.addAction(self.action_hide)
self.action_check = QAction("Comprobar actualizaciones", self) self.action_check = QAction("Comprova actualitzacions", self)
self.action_check.triggered.connect(self._check_updates) self.action_check.triggered.connect(self._check_updates)
menu.addAction(self.action_check) menu.addAction(self.action_check)
@@ -107,7 +110,7 @@ class MainWindow(QMainWindow):
def _check_updates(self) -> None: def _check_updates(self) -> None:
self.action_check.setEnabled(False) self.action_check.setEnabled(False)
self._log("=== Comprobando actualizaciones ===") self._log("=== Comprovant actualitzacions ===")
worker = CheckUpdatesWorker(self.root, self.config.games) worker = CheckUpdatesWorker(self.root, self.config.games)
worker.signals.log.connect(self._log) worker.signals.log.connect(self._log)
worker.signals.result.connect(self._mark_update) worker.signals.result.connect(self._mark_update)
@@ -127,11 +130,11 @@ class MainWindow(QMainWindow):
def _check_done(self, _payload) -> None: def _check_done(self, _payload) -> None:
self.action_check.setEnabled(True) self.action_check.setEnabled(True)
self._log("=== Comprobación de actualizaciones terminada ===") self._log("=== Comprovació d'actualitzacions acabada ===")
def _check_error(self, msg: str) -> None: def _check_error(self, msg: str) -> None:
self.action_check.setEnabled(True) self.action_check.setEnabled(True)
self._log(f"!!! Error comprobando actualizaciones: {msg}") self._log(f"!!! Error comprovant actualitzacions: {msg}")
# --------------------------------------------------------------- helpers # --------------------------------------------------------------- helpers
@@ -146,12 +149,12 @@ class MainWindow(QMainWindow):
worker.signals.finished.connect(lambda *_: self._workers.discard(worker)) worker.signals.finished.connect(lambda *_: self._workers.discard(worker))
worker.signals.error.connect(lambda *_: self._workers.discard(worker)) worker.signals.error.connect(lambda *_: self._workers.discard(worker))
# --------------------------------------------------------------- acciones # --------------------------------------------------------------- accions
def _on_download(self, game: Game) -> None: def _on_download(self, game: Game) -> None:
row = self.rows[game.id] row = self.rows[game.id]
row.set_busy(True, "Descargando") row.set_busy(True, "Descarregant")
self._log(f"=== Download: {game.name} ===") self._log(f"=== Descàrrega: {game.name} ===")
worker = DownloadWorker(self.root, game) worker = DownloadWorker(self.root, game)
worker.signals.log.connect(self._log) worker.signals.log.connect(self._log)
@@ -161,7 +164,7 @@ class MainWindow(QMainWindow):
self.pool.start(worker) self.pool.start(worker)
def _download_done(self, game: Game) -> None: def _download_done(self, game: Game) -> None:
self._log(f"=== {game.name}: descarga completada ===") self._log(f"=== {game.name}: descàrrega completada ===")
row = self.rows[game.id] row = self.rows[game.id]
row.set_busy(False) row.set_busy(False)
row.set_update_available(False) # recién traído del remoto → al día row.set_update_available(False) # recién traído del remoto → al día
@@ -174,8 +177,8 @@ class MainWindow(QMainWindow):
def _on_run(self, game: Game) -> None: def _on_run(self, game: Game) -> None:
row = self.rows[game.id] row = self.rows[game.id]
row.set_busy(True, "Ejecutando") row.set_busy(True, "Executant")
self._log(f"=== Run: {game.name} ===") self._log(f"=== Juga: {game.name} ===")
worker = RunWorker(self.root, game) worker = RunWorker(self.root, game)
worker.signals.log.connect(self._log) worker.signals.log.connect(self._log)
@@ -185,7 +188,7 @@ class MainWindow(QMainWindow):
self.pool.start(worker) self.pool.start(worker)
def _run_done(self, game: Game, code: int) -> None: def _run_done(self, game: Game, code: int) -> None:
self._log(f"=== {game.name}: finalizó con código {code} ===") self._log(f"=== {game.name}: ha finalitzat amb codi {code} ===")
row = self.rows[game.id] row = self.rows[game.id]
row.set_busy(False) row.set_busy(False)
row.refresh() row.refresh()
@@ -195,3 +198,26 @@ class MainWindow(QMainWindow):
row = self.rows[game.id] row = self.rows[game.id]
row.set_busy(False) row.set_busy(False)
row.refresh() row.refresh()
def _on_delete(self, game: Game) -> None:
resp = QMessageBox.question(
self,
"Esborrar descàrrega",
f"Segur que vols esborrar la descàrrega local de «{game.name}»?\n\n"
"S'eliminarà el clon i les dades en local (no es treu de la llista).",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if resp != QMessageBox.Yes:
return
self._log(f"=== Esborra: {game.name} ===")
gitops.delete_local(self.root, game, log=self._log)
if game.id in self.settings.updates_pending:
self.settings.updates_pending = [
g for g in self.settings.updates_pending if g != game.id
]
save_settings(self.settings)
row = self.rows[game.id]
row.set_update_available(False)
row.refresh()
self._apply_filter() # si està actiu "amaga no descarregats", ara s'amaga