diff --git a/README.md b/README.md index 63ce494..6a684b1 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,22 @@ jlauncher_data/ 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/` + `git clean -fd`), descartando cualquier cambio local. Después refresca la metadata: - descripción desde la API de Gitea (`/api/v1/repos//`), - versión ejecutando `version_cmd` (por defecto `git describe --tags --always`), - 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. +- **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. diff --git a/jlauncher/__main__.py b/jlauncher/__main__.py index 606d919..0908b2e 100644 --- a/jlauncher/__main__.py +++ b/jlauncher/__main__.py @@ -24,8 +24,8 @@ def main() -> int: except Exception as exc: # noqa: BLE001 - mostrar cualquier error de carga al usuario QMessageBox.critical( None, - "Error cargando games.toml", - f"No se pudo leer la configuración en:\n{cfg_path}\n\n{exc}", + "Error carregant games.toml", + f"No s'ha pogut llegir la configuració a:\n{cfg_path}\n\n{exc}", ) return 1 diff --git a/jlauncher/gitops.py b/jlauncher/gitops.py index 0a99c19..b32f33b 100644 --- a/jlauncher/gitops.py +++ b/jlauncher/gitops.py @@ -17,7 +17,7 @@ from pathlib import Path from .config import Game 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] @@ -76,6 +76,16 @@ def check_update(root: Path, game: Game, log: LogFn = _noop) -> bool: 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: """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) 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) 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) else: - log(f"Clonando {game.name}…") + 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) @@ -131,14 +141,14 @@ def refresh_metadata( def _copy_icon(root: Path, game: Game, repo: Path, log: LogFn) -> None: src = repo / game.icon_rel if not src.exists(): - log(f"(sin icono en {game.icon_rel})") + log(f"(sense icona a {game.icon_rel})") return metadata_dir(root, game.id).mkdir(parents=True, exist_ok=True) try: 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: - 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: @@ -154,7 +164,7 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str: timeout=20, ) except (OSError, subprocess.SubprocessError) as exc: - log(f"version_cmd falló: {exc}") + log(f"version_cmd ha fallat: {exc}") return "" out = (proc.stdout or "").strip() 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: return json.loads(resp.read().decode("utf-8")) 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 diff --git a/jlauncher/runner.py b/jlauncher/runner.py index 5228d26..a39317c 100644 --- a/jlauncher/runner.py +++ b/jlauncher/runner.py @@ -43,15 +43,15 @@ def run_game(root: Path, game: Game, log: LogFn = _noop) -> int: repo = repo_dir(root, game.id) if not (repo / ".git").exists(): 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(): - log(f"Compilando {game.name}…") + log(f"Compilant {game.name}…") code = _stream(game.build_cmd, repo, log) 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 - log(f"Ejecutando {game.name}…") + log(f"Executant {game.name}…") return _stream(game.run_cmd, repo, log) diff --git a/jlauncher/ui/game_row.py b/jlauncher/ui/game_row.py index 516ad8d..f87a7c6 100644 --- a/jlauncher/ui/game_row.py +++ b/jlauncher/ui/game_row.py @@ -4,14 +4,14 @@ from __future__ import annotations 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.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QPushButton, - QSizePolicy, + QStyle, QVBoxLayout, ) @@ -27,6 +27,7 @@ class GameRow(QFrame): download_requested = Signal(object) # Game run_requested = Signal(object) # Game + delete_requested = Signal(object) # Game def __init__(self, game: Game, root: Path, parent=None) -> None: super().__init__(parent) @@ -63,17 +64,31 @@ class GameRow(QFrame): text_box.addStretch(1) layout.addLayout(text_box, stretch=1) - # --- Botones --- - self.download_btn = QPushButton("Download") - self.run_btn = QPushButton("Run") - for btn in (self.download_btn, self.run_btn): - btn.setMinimumWidth(96) - btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + # --- Botons (icones compactes amb tooltip; visibilitat segons l'estat) --- + style = self.style() + self._icon_download = style.standardIcon(QStyle.SP_ArrowDown) + self._icon_update = style.standardIcon(QStyle.SP_BrowserReload) + + 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.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.run_btn) + btn_box.addWidget(self.delete_btn) layout.addLayout(btn_box) self.refresh() @@ -92,7 +107,7 @@ class GameRow(QFrame): """Recarga icono, descripción y estado desde la cache local.""" meta = load_meta(self.root, self.game.id) 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) def _set_icon(self) -> None: @@ -113,18 +128,32 @@ class GameRow(QFrame): ) def _set_status(self, meta: GameMeta) -> None: - if not self.is_installed(): - self.status_label.setText("No instalado") + installed = self.is_installed() + + # 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.run_btn.setEnabled(True) # se permite, avisará si no está elif self._update_available: - self.status_label.setText("⬆ Actualización disponible") + self.status_label.setText("⬆ Actualització disponible") self.status_label.setStyleSheet( "color: #e0a030; font-weight: bold; font-size: 11px;" ) 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;") # --------------------------------------------------------------- busy UI @@ -132,6 +161,7 @@ class GameRow(QFrame): def set_busy(self, busy: bool, message: str = "") -> None: self.download_btn.setEnabled(not busy) self.run_btn.setEnabled(not busy) + self.delete_btn.setEnabled(not busy) if busy and message: self.status_label.setText(message) self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;") diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py index cebb3e8..8b647ae 100644 --- a/jlauncher/ui/main_window.py +++ b/jlauncher/ui/main_window.py @@ -8,6 +8,7 @@ from PySide6.QtCore import QThreadPool, Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QMainWindow, + QMessageBox, QPlainTextEdit, QScrollArea, QSplitter, @@ -15,6 +16,7 @@ from PySide6.QtWidgets import ( QWidget, ) +from .. import gitops from ..config import Config, Game from ..settings import load_settings, save_settings from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker @@ -53,6 +55,7 @@ class MainWindow(QMainWindow): row = GameRow(game, root) row.download_requested.connect(self._on_download) row.run_requested.connect(self._on_run) + row.delete_requested.connect(self._on_delete) self.rows[game.id] = row list_layout.addWidget(row) list_layout.addStretch(1) @@ -82,14 +85,14 @@ class MainWindow(QMainWindow): # --------------------------------------------------------------- menú 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.toggled.connect(self._on_toggle_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) menu.addAction(self.action_check) @@ -107,7 +110,7 @@ class MainWindow(QMainWindow): def _check_updates(self) -> None: self.action_check.setEnabled(False) - self._log("=== Comprobando actualizaciones ===") + self._log("=== Comprovant actualitzacions ===") worker = CheckUpdatesWorker(self.root, self.config.games) worker.signals.log.connect(self._log) worker.signals.result.connect(self._mark_update) @@ -127,11 +130,11 @@ class MainWindow(QMainWindow): def _check_done(self, _payload) -> None: 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: self.action_check.setEnabled(True) - self._log(f"!!! Error comprobando actualizaciones: {msg}") + self._log(f"!!! Error comprovant actualitzacions: {msg}") # --------------------------------------------------------------- helpers @@ -146,12 +149,12 @@ class MainWindow(QMainWindow): worker.signals.finished.connect(lambda *_: self._workers.discard(worker)) worker.signals.error.connect(lambda *_: self._workers.discard(worker)) - # --------------------------------------------------------------- acciones + # --------------------------------------------------------------- accions def _on_download(self, game: Game) -> None: row = self.rows[game.id] - row.set_busy(True, "Descargando…") - self._log(f"=== Download: {game.name} ===") + row.set_busy(True, "Descarregant…") + self._log(f"=== Descàrrega: {game.name} ===") worker = DownloadWorker(self.root, game) worker.signals.log.connect(self._log) @@ -161,7 +164,7 @@ class MainWindow(QMainWindow): self.pool.start(worker) 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.set_busy(False) 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: row = self.rows[game.id] - row.set_busy(True, "Ejecutando…") - self._log(f"=== Run: {game.name} ===") + row.set_busy(True, "Executant…") + self._log(f"=== Juga: {game.name} ===") worker = RunWorker(self.root, game) worker.signals.log.connect(self._log) @@ -185,7 +188,7 @@ class MainWindow(QMainWindow): self.pool.start(worker) 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.set_busy(False) row.refresh() @@ -195,3 +198,26 @@ class MainWindow(QMainWindow): row = self.rows[game.id] row.set_busy(False) 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