"""Ventana principal: lista de juegos con scroll + panel de log.""" from __future__ import annotations from pathlib import Path from PySide6.QtCore import 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 ..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" class MainWindow(QMainWindow): def __init__(self, config: Config, root: Path, parent=None) -> None: super().__init__(parent) self.config = config self.root = root self.settings = load_settings() self.pool = QThreadPool.globalInstance() self.rows: dict[str, GameRow] = {} self._delete_mode = False # Mantener referencias a los workers en vuelo: si no, Python los recolecta # (junto a su objeto de señales) antes de que la señal en cola `finished` # llegue al hilo principal, y la UI nunca se refresca. self._workers: set = set() 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) # --- Lista de juegos con scroll --- list_container = QWidget() list_layout = QVBoxLayout(list_container) list_layout.setContentsMargins(6, 6, 6, 6) list_layout.setSpacing(6) for game in config.games: row = GameRow(game, root) row.activated.connect(self._on_activate) row.delete_requested.connect(self._on_delete) self.rows[game.id] = row list_layout.addWidget(row) list_layout.addStretch(1) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(list_container) splitter.addWidget(scroll) # --- Panel de log --- 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.setCentralWidget(splitter) # 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: if game_id in self.rows: 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, ) # --------------------------------------------------------------- menú def _build_menu(self) -> None: menu = self.menuBar().addMenu("Opcions") 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("Comprova actualitzacions", self) 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) menu.addSeparator() self.action_delete = QAction("Esborra un joc", self, checkable=True) self.action_delete.toggled.connect(self._set_delete_mode) menu.addAction(self.action_delete) menu.addSeparator() self.action_token = QAction("Configura el token de Gitea…", self) self.action_token.triggered.connect(self._configure_token) menu.addAction(self.action_token) def _build_theme_menu(self, parent_menu) -> None: """Submenú Tema amb tres opcions exclusives: Sistema / Clar / Fosc.""" submenu = parent_menu.addMenu("Tema") group = QActionGroup(self) group.setExclusive(True) options = [ ("Sistema", theme.THEME_SYSTEM), ("Clar", theme.THEME_LIGHT), ("Fosc", theme.THEME_DARK), ] for label, mode in options: action = QAction(label, self, checkable=True) action.setChecked(self.settings.theme == mode) action.triggered.connect(lambda _checked, m=mode: self._on_theme_selected(m)) group.addAction(action) submenu.addAction(action) def _on_theme_selected(self, mode: str) -> None: self.settings.theme = mode save_settings(self.settings) app = QApplication.instance() if app is not None: theme.apply_theme(app, mode) def _configure_token(self) -> None: token, ok = QInputDialog.getText( self, "Token de Gitea", "Token personal d'accés (per a repos privats).\n" "Es guarda local a settings.json (no es versiona).", QLineEdit.Password, self.settings.gitea_token, ) if not ok: return self.settings.gitea_token = token.strip() save_settings(self.settings) estat = "configurat" if self.settings.gitea_token else "esborrat" self._log(f"Token de Gitea {estat}.") def _on_toggle_hide(self, checked: bool) -> None: self.settings.hide_not_downloaded = checked 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(): row.setVisible(not (hide and not row.is_installed())) # ------------------------------------------------------ comprobar updates 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, 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: row.set_update_available(has_update) pending = set(self.settings.updates_pending) pending.add(game_id) if has_update else pending.discard(game_id) self.settings.updates_pending = sorted(pending) save_settings(self.settings) 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) 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)) # --------------------------------------------------------------- accions def _set_delete_mode(self, on: bool) -> None: 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.""" row = self.rows[game.id] if self._delete_mode: if not row.is_installed(): return # res a esborrar; segueix en mode esborrar if self._on_delete(game): self.action_delete.setChecked(False) # surt del mode esborrar return if row.primary_action_is_download(): self._on_download(game) else: self._on_run(game) def _on_download(self, game: Game) -> None: row = self.rows[game.id] row.set_busy(True, "Descarregant…") self._log(f"=== Descàrrega: {game.name} ===") 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)) self._track(worker) self.pool.start(worker) def _download_done(self, game: Game) -> None: 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 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) self._apply_filter() # un juego antes no instalado puede aparecer ahora def _on_run(self, game: Game) -> None: row = self.rows[game.id] row.set_busy(True, "Executant…") self._log(f"=== Juga: {game.name} ===") worker = RunWorker(self.root, game) worker.signals.log.connect(self._log) worker.signals.finished.connect(lambda code, g=game: self._run_done(g, code)) worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg)) self._track(worker) self.pool.start(worker) def _run_done(self, game: Game, code: int) -> None: self._log(f"=== {game.name}: ha finalitzat amb codi {code} ===") row = self.rows[game.id] row.set_busy(False) row.refresh() def _op_error(self, game: Game, msg: str) -> None: self._log(f"!!! {game.name}: ERROR: {msg}") row = self.rows[game.id] row.set_busy(False) row.refresh() def _on_delete(self, game: Game) -> bool: """Esborra la descàrrega local (amb confirmació). Retorna True si s'ha esborrat.""" 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 False 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 return True