"""Ventana principal: lista de juegos con scroll + panel de log.""" from __future__ import annotations from pathlib import Path from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QThreadPool, Qt, QTimer from PySide6.QtGui import QAction, QActionGroup, QPixmap from PySide6.QtWidgets import ( QApplication, QDialog, QDialogButtonBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QScrollArea, QVBoxLayout, QWidget, ) from .. import __version__, gitops from ..config import Config, Game from ..paths import app_icon_path 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 = 3000 # marge sense activitat abans de replegar en mode auto CONSOLE_SHOW = "show" CONSOLE_AUTO = "auto" CONSOLE_HIDE = "hide" SORT_DEFAULT = "default" # ordre del games.toml SORT_NAME = "name" # alfabètic pel nom 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() 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() self.list_layout = QVBoxLayout(list_container) self.list_layout.setContentsMargins(6, 6, 6, 6) self.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 self._populate_list() scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(list_container) root_layout.addWidget(scroll, stretch=1) # --- 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;") self.log_view.setMinimumHeight(0) root_layout.addWidget(self.log_view) 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: 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, ) # --------------------------------------------------------------- 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: 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_sort_menu(menu) 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) 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) 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: """Diàleg «Quant a» personalitzat: icona, nom gran i tot centrat.""" dlg = QDialog(self) dlg.setWindowTitle(f"Quant a {APP_NAME}") dlg.setModal(True) lay = QVBoxLayout(dlg) lay.setContentsMargins(40, 30, 40, 24) lay.setSpacing(0) # Logo (si el trobem); s'escala suau des del PNG gran. icon_path = app_icon_path() if icon_path is not None: pix = QPixmap(str(icon_path)) if not pix.isNull(): logo = QLabel(alignment=Qt.AlignCenter) logo.setPixmap( pix.scaled( 96, 96, Qt.KeepAspectRatio, Qt.SmoothTransformation ) ) lay.addWidget(logo) lay.addSpacing(16) # Nom de l'app: gran, en negreta i amb el morat de la marca. name = QLabel(APP_NAME, alignment=Qt.AlignCenter) nf = name.font() nf.setPointSize(nf.pointSize() + 13) nf.setBold(True) name.setFont(nf) name.setStyleSheet("color: #7c4dff;") lay.addWidget(name) lay.addSpacing(2) # Versió: petita i atenuada, just davall del nom (estil macOS). ver = QLabel(f"v{__version__}", alignment=Qt.AlignCenter) vf = ver.font() vf.setPointSize(vf.pointSize() - 1) ver.setFont(vf) ver.setStyleSheet("color: #8a8a8a;") lay.addWidget(ver) lay.addSpacing(18) # Lema discret. tag = QLabel("Clona, compila i juga · jailgames", alignment=Qt.AlignCenter) tag.setStyleSheet("color: #8a8a8a;") lay.addWidget(tag) lay.addSpacing(14) copy = QLabel("© 2026 JailDesigner", alignment=Qt.AlignCenter) cf = copy.font() cf.setPointSize(cf.pointSize() - 1) copy.setFont(cf) lay.addWidget(copy) lay.addSpacing(24) # Botó OK centrat. buttons = QDialogButtonBox(QDialogButtonBox.Ok) buttons.button(QDialogButtonBox.Ok).setText("D'acord") buttons.accepted.connect(dlg.accept) row = QHBoxLayout() row.addStretch(1) row.addWidget(buttons) row.addStretch(1) lay.addLayout(row) dlg.exec() def _build_sort_menu(self, parent_menu) -> None: """Submenú Ordena amb dues opcions exclusives: Per defecte / Per nom.""" submenu = parent_menu.addMenu("Ordena") group = QActionGroup(self) group.setExclusive(True) options = [ ("Per defecte", SORT_DEFAULT), ("Per nom", SORT_NAME), ] for label, mode in options: action = QAction(label, self, checkable=True) action.setChecked(self.settings.sort_order == mode) action.triggered.connect(lambda _checked, m=mode: self._on_sort_selected(m)) group.addAction(action) submenu.addAction(action) def _on_sort_selected(self, mode: str) -> None: self.settings.sort_order = mode save_settings(self.settings) self._populate_list() 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, "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 _ordered_games(self) -> list[Game]: """Jocs en l'ordre triat: alfabètic pel nom, o l'ordre original del games.toml.""" if self.settings.sort_order == SORT_NAME: return sorted(self.config.games, key=lambda g: g.name.casefold()) return list(self.config.games) def _populate_list(self) -> None: """(Re)col·loca les files al layout segons l'ordre triat, sense destruir-les.""" while self.list_layout.count(): item = self.list_layout.takeAt(0) w = item.widget() if item else None if w is not None: w.setParent(None) # treu del layout però conserva la fila (viu a self.rows) for game in self._ordered_games(): self.list_layout.addWidget(self.rows[game.id]) self.list_layout.addStretch(1) 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: # Autoscroll intel·ligent: només seguim el final si la barra ja hi estava. # Si l'usuari ha pujat a llegir una línia anterior, no l'arrosseguem avall. bar = self.log_view.verticalScrollBar() at_bottom = bar.value() >= bar.maximum() - 4 self.log_view.appendPlainText(text) if at_bottom: bar.setValue(bar.maximum()) # 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) 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 def _set_delete_mode(self, on: bool) -> None: self._delete_mode = on for row in self.rows.values(): row.set_delete_mode(on) 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