629 lines
25 KiB
Python
629 lines
25 KiB
Python
"""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
|