Llançador inicial amb GUI PySide6: descàrrega i execució de jocs
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Componentes de interfaz de jlauncher."""
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Fila de la lista: icono, nombre, descripción y botones Download/Run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..config import Game
|
||||
from ..metadata import GameMeta, icon_path, load_meta
|
||||
from ..paths import repo_dir
|
||||
|
||||
ICON_SIZE = 64
|
||||
|
||||
|
||||
class GameRow(QFrame):
|
||||
"""Widget de una fila. Emite señales cuando se pulsan los botones."""
|
||||
|
||||
download_requested = Signal(object) # Game
|
||||
run_requested = Signal(object) # Game
|
||||
|
||||
def __init__(self, game: Game, root: Path, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.game = game
|
||||
self.root = root
|
||||
self.setObjectName("gameRow")
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# --- Icono ---
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
|
||||
self.icon_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.icon_label)
|
||||
|
||||
# --- Texto (nombre + descripción + estado) ---
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(2)
|
||||
self.name_label = QLabel(game.name)
|
||||
self.name_label.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
self.desc_label = QLabel("")
|
||||
self.desc_label.setWordWrap(True)
|
||||
self.desc_label.setStyleSheet("color: #aaaaaa;")
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
|
||||
text_box.addWidget(self.name_label)
|
||||
text_box.addWidget(self.desc_label)
|
||||
text_box.addWidget(self.status_label)
|
||||
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)
|
||||
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()
|
||||
btn_box.addWidget(self.download_btn)
|
||||
btn_box.addWidget(self.run_btn)
|
||||
layout.addLayout(btn_box)
|
||||
|
||||
self.refresh()
|
||||
|
||||
# ----------------------------------------------------------------- estado
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""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._set_status(meta)
|
||||
|
||||
def _set_icon(self) -> None:
|
||||
path = icon_path(self.root, self.game.id)
|
||||
pixmap = QPixmap(str(path)) if path.exists() else QPixmap()
|
||||
if pixmap.isNull():
|
||||
self.icon_label.setText("🎮")
|
||||
self.icon_label.setStyleSheet("font-size: 32px;")
|
||||
else:
|
||||
self.icon_label.setStyleSheet("")
|
||||
self.icon_label.setPixmap(
|
||||
pixmap.scaled(
|
||||
ICON_SIZE,
|
||||
ICON_SIZE,
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
def _set_status(self, meta: GameMeta) -> None:
|
||||
installed = (repo_dir(self.root, self.game.id) / ".git").exists()
|
||||
if not installed:
|
||||
self.status_label.setText("No instalado")
|
||||
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
|
||||
self.run_btn.setEnabled(True) # se permite, avisará si no está
|
||||
else:
|
||||
ver = f" {meta.version}" if meta.version else ""
|
||||
self.status_label.setText(f"Instalado{ver}")
|
||||
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
|
||||
|
||||
# --------------------------------------------------------------- busy UI
|
||||
|
||||
def set_busy(self, busy: bool, message: str = "") -> None:
|
||||
self.download_btn.setEnabled(not busy)
|
||||
self.run_btn.setEnabled(not busy)
|
||||
if busy and message:
|
||||
self.status_label.setText(message)
|
||||
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Ventana principal: lista de juegos con scroll + panel de log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThreadPool, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import Config, Game
|
||||
from ..workers import DownloadWorker, RunWorker
|
||||
from .game_row import GameRow
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, config: Config, root: Path, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.root = root
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self.rows: dict[str, GameRow] = {}
|
||||
# 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("jlauncher — Juegos jailgames")
|
||||
self.resize(720, 640)
|
||||
|
||||
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.download_requested.connect(self._on_download)
|
||||
row.run_requested.connect(self._on_run)
|
||||
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; background:#1e1e1e; color:#d4d4d4;"
|
||||
)
|
||||
splitter.addWidget(self.log_view)
|
||||
splitter.setStretchFactor(0, 3)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
|
||||
self.setCentralWidget(splitter)
|
||||
|
||||
# --------------------------------------------------------------- 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))
|
||||
|
||||
# --------------------------------------------------------------- acciones
|
||||
|
||||
def _on_download(self, game: Game) -> None:
|
||||
row = self.rows[game.id]
|
||||
row.set_busy(True, "Descargando…")
|
||||
self._log(f"=== Download: {game.name} ===")
|
||||
|
||||
worker = DownloadWorker(self.root, game)
|
||||
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}: descarga completada ===")
|
||||
row = self.rows[game.id]
|
||||
row.set_busy(False)
|
||||
row.refresh()
|
||||
|
||||
def _on_run(self, game: Game) -> None:
|
||||
row = self.rows[game.id]
|
||||
row.set_busy(True, "Ejecutando…")
|
||||
self._log(f"=== Run: {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}: finalizó con código {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()
|
||||
Reference in New Issue
Block a user