Files
jail-launcher/jlauncher/ui/main_window.py
T
JailDesigner 93efbb06c4 Refresca bé el canvi de tema en calent
setPalette no repolia els widgets ja creats: els stylesheets amb palette(...)
(pills) no es reresolien i la consola de log no repintava el fons. apply_theme
ara fa unpolish→polish→update a tots els widgets, i en canviar de tema es
reconstrueixen les files perquè els pills agafin la paleta nova.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:20:47 +02:00

368 lines
14 KiB
Python

"""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)
# 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 _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