refactor: renombra el paquet a jail_launcher i l'app a «Jail Launcher»

This commit is contained in:
2026-06-01 17:46:09 +02:00
parent 878c403654
commit fccbdc83d5
23 changed files with 78 additions and 77 deletions
+1
View File
@@ -0,0 +1 @@
"""Componentes de interfaz de jail-launcher."""
+71
View File
@@ -0,0 +1,71 @@
"""Layout que coloca los widgets en fila y salta de línea cuando no caben.
Port mínimo del ejemplo FlowLayout de Qt: se usa para los 'pills' de cada fila,
que pueden ser muchos (topics) y deben envolver sin desbordar horizontalmente.
"""
from __future__ import annotations
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QWidget
class FlowLayout(QLayout):
def __init__(self, parent: QWidget | None = None, spacing: int = 6) -> None:
super().__init__(parent)
self._items: list[QLayoutItem] = []
self._spacing = spacing
self.setContentsMargins(0, 0, 0, 0)
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - API Qt
self._items.append(item)
def count(self) -> int:
return len(self._items)
def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
return self._items.pop(index) if 0 <= index < len(self._items) else None
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - API Qt
return Qt.Orientation(0)
def hasHeightForWidth(self) -> bool: # noqa: N802 - API Qt
return True
def heightForWidth(self, width: int) -> int: # noqa: N802 - API Qt
return self._layout(QRect(0, 0, width, 0), apply=False)
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - API Qt
super().setGeometry(rect)
self._layout(rect, apply=True)
def sizeHint(self) -> QSize: # noqa: N802 - API Qt
return self.minimumSize()
def minimumSize(self) -> QSize: # noqa: N802 - API Qt
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
m: QMargins = self.contentsMargins()
return size + QSize(m.left() + m.right(), m.top() + m.bottom())
def _layout(self, rect: QRect, apply: bool) -> int:
m: QMargins = self.contentsMargins()
eff = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())
x, y, line_h = eff.x(), eff.y(), 0
for item in self._items:
hint = item.sizeHint()
next_x = x + hint.width() + self._spacing
if next_x - self._spacing > eff.right() and line_h > 0:
x = eff.x()
y = y + line_h + self._spacing
next_x = x + hint.width() + self._spacing
line_h = 0
if apply:
item.setGeometry(QRect(QPoint(x, y), hint))
x = next_x
line_h = max(line_h, hint.height())
return y + line_h - rect.y() + m.bottom()
+288
View File
@@ -0,0 +1,288 @@
"""Fila de la llista: la fila sencera actua com un botó, amb estil de targeta.
Un clic fa l'acció principal segons l'estat:
- no descarregat → descarrega
- update pendent → actualitza
- descarregat i al dia → juga
A la dreta un indicador de text (passiu) mostra què farà el clic. Sota el títol i la
descripció es pinten 'pills' amb la metadata: estat, versió, data, jugadors, autor i
els topics de Gitea. L'esborrat es gestiona per fora; aquest widget només l'exposa.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QRectF, Qt, Signal
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..config import Game
from ..metadata import GameMeta, icon_path, load_meta
from ..paths import repo_dir
from .flow_layout import FlowLayout
ICON_SIZE = 64
ICON_RADIUS = 14
# Colors d'estat (text + fons translúcid del pill).
_STATUS_COLORS = {
"none": ("#cc8855", "rgba(204, 136, 85, 0.18)"), # no descarregat
"update": ("#e0a030", "rgba(224, 160, 48, 0.20)"), # actualització disponible
"ok": ("#6fae6f", "rgba(111, 174, 111, 0.18)"), # descarregat i al dia
}
_NEUTRAL_BG = "rgba(127, 127, 127, 0.18)" # pills informatius (segueix clar/fosc)
def _rounded_pixmap(src: QPixmap, size: int, radius: int) -> QPixmap:
"""Escala omplint el quadrat, retalla al centre i arrodoneix les cantonades."""
scaled = src.scaled(
size, size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation
)
x = max(0, (scaled.width() - size) // 2)
y = max(0, (scaled.height() - size) // 2)
cropped = scaled.copy(x, y, size, size)
out = QPixmap(size, size)
out.fill(Qt.transparent)
painter = QPainter(out)
painter.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addRoundedRect(QRectF(0, 0, size, size), radius, radius)
painter.setClipPath(path)
painter.drawPixmap(0, 0, cropped)
painter.end()
return out
def _palette_text_color() -> str:
"""Color de text actual de la paleta, com a rgb() concret (no 'palette(text)',
que Qt cacheja als stylesheets i no es refresca en canviar de tema)."""
c = QApplication.palette().color(QPalette.WindowText)
return f"rgb({c.red()}, {c.green()}, {c.blue()})"
def _make_pill(text: str, fg: str | None = None, bg: str = _NEUTRAL_BG,
bold: bool = False) -> QLabel:
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí.
Sense `fg` explícit s'usa el color de text de la paleta vigent (pills neutres).
"""
pill = QLabel(text)
pill.setAttribute(Qt.WA_TransparentForMouseEvents)
weight = "600" if bold else "normal"
pill.setStyleSheet(
f"QLabel {{ background: {bg}; color: {fg or _palette_text_color()}; "
f"border-radius: 9px; padding: 2px 8px; font-size: 11px; "
f"font-weight: {weight}; }}"
)
return pill
class GameRow(QFrame):
"""Fila clicable. Emet `activated` en clic i `delete_requested` per a l'esborrat."""
activated = Signal(object) # Game — clic sobre la fila (acció principal)
delete_requested = Signal(object) # Game — esborrar la descàrrega local
def __init__(self, game: Game, root: Path, parent=None) -> None:
super().__init__(parent)
self.game = game
self.root = root
self._update_available = False
self._busy = False
self._delete_mode = False
self.setObjectName("gameRow")
self.setFrameShape(QFrame.StyledPanel)
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(12)
# --- Icona del joc (arrodonida) ---
self.icon_label = QLabel()
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
self.icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.icon_label, alignment=Qt.AlignTop)
# --- Text: títol + descripció + pills (centrat verticalment) ---
text_box = QVBoxLayout()
text_box.setSpacing(4)
self.name_label = QLabel(game.name)
self.name_label.setStyleSheet("font-weight: bold; font-size: 18px;")
self.desc_label = QLabel("")
self.desc_label.setWordWrap(True)
self.desc_label.setForegroundRole(QPalette.PlaceholderText)
# Contenidor de pills amb FlowLayout (envolten si no caben).
self.pills_box = QWidget()
self.pills_layout = FlowLayout(self.pills_box, spacing=6)
self.pills_box.setAttribute(Qt.WA_TransparentForMouseEvents)
# Sense fons propi: que no pinti cap banda darrere dels pills.
self.pills_box.setAttribute(Qt.WA_NoSystemBackground, True)
self.pills_box.setAutoFillBackground(False)
sp = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
sp.setHeightForWidth(True)
self.pills_box.setSizePolicy(sp)
text_box.addStretch(1)
text_box.addWidget(self.name_label)
text_box.addWidget(self.desc_label)
text_box.addWidget(self.pills_box)
text_box.addStretch(1)
layout.addLayout(text_box, stretch=1)
# --- Indicador d'acció (passiu, text): què farà el clic ---
self.action_label = QLabel()
self.action_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.action_label.setMinimumWidth(96)
layout.addWidget(self.action_label)
for child in (self.name_label, self.desc_label, self.action_label):
child.setAttribute(Qt.WA_TransparentForMouseEvents)
self.refresh()
# ------------------------------------------------------------- clic fila
def mouseReleaseEvent(self, event) -> None:
if (
event.button() == Qt.LeftButton
and not self._busy
and self.rect().contains(event.position().toPoint())
):
self.activated.emit(self.game)
super().mouseReleaseEvent(event)
# ----------------------------------------------------------------- estat
def is_installed(self) -> bool:
return (repo_dir(self.root, self.game.id) / ".git").exists()
def primary_action_is_download(self) -> bool:
"""True si el clic ha de descarregar/actualitzar; False si ha de jugar."""
return (not self.is_installed()) or self._update_available
def set_update_available(self, available: bool) -> None:
"""Marca/desmarca la fila com a 'té actualització pendent'."""
self._update_available = available
self.refresh()
def set_delete_mode(self, on: bool) -> None:
"""Activa/desactiva el mode esborrar (canvia el text d'acció a 'Esborra')."""
self._delete_mode = on
self.refresh()
def refresh(self) -> None:
"""Recarrega icona, descripció, pills i estat des de la cache local."""
meta = load_meta(self.root, self.game.id)
self._set_icon()
self.desc_label.setText(meta.description or "(sense descripció encara)")
self._rebuild_pills(meta)
self._set_action(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(_rounded_pixmap(pixmap, ICON_SIZE, ICON_RADIUS))
# ----------------------------------------------------------------- pills
def _clear_pills(self) -> None:
while self.pills_layout.count():
item = self.pills_layout.takeAt(0)
w = item.widget() if item else None
if w is not None:
w.deleteLater()
def _rebuild_pills(self, meta: GameMeta) -> None:
self._clear_pills()
installed = self.is_installed()
# 1) Estat (pill amb color).
if not installed:
fg, bg = _STATUS_COLORS["none"]
self.pills_layout.addWidget(_make_pill("No descarregat", fg, bg, bold=True))
elif self._update_available:
fg, bg = _STATUS_COLORS["update"]
self.pills_layout.addWidget(
_make_pill("⬆ Actualització", fg, bg, bold=True)
)
else:
fg, bg = _STATUS_COLORS["ok"]
self.pills_layout.addWidget(_make_pill("✓ Descarregat", fg, bg, bold=True))
# 2) Versió (només si està descarregat i la coneixem).
if installed and meta.version:
self.pills_layout.addWidget(_make_pill(meta.version))
# 3) Data de llançament (creació del repo a Gitea).
released = _year_month_day(meta.created_at)
if released:
self.pills_layout.addWidget(_make_pill(released))
# 4) Jugadors i autor (manuals, de games.toml).
if self.game.players:
self.pills_layout.addWidget(_make_pill(self.game.players))
if self.game.author:
self.pills_layout.addWidget(_make_pill(self.game.author))
# 5) Topics de Gitea.
for topic in meta.topics:
self.pills_layout.addWidget(_make_pill(topic))
# --------------------------------------------------------------- acció
def _set_action(self, meta: GameMeta) -> None:
installed = self.is_installed()
if self._delete_mode:
if installed:
self._set_action_text("Esborra", "#d9534f")
else:
self.action_label.clear() # no es pot esborrar el que no està
elif not installed:
self._set_action_text("Descarrega", "#4a90d9")
elif self._update_available:
self._set_action_text("Actualitza", "#e0a030")
else:
self._set_action_text("Juga", "#6fae6f")
def _set_action_text(self, text: str, color: str) -> None:
self.action_label.setText(text)
self.action_label.setStyleSheet(
f"color: {color}; font-weight: bold; font-size: 13px;"
)
# --------------------------------------------------------------- busy UI
def set_busy(self, busy: bool, message: str = "") -> None:
self._busy = busy
if busy and message:
self._clear_pills()
self.pills_layout.addWidget(_make_pill(message, "#d0d060"))
def _year_month_day(iso: str) -> str:
"""De un ISO-8601 (2026-04-05T20:26:09+02:00) treu '2026-04-05'; '' si no és vàlid."""
if not iso or len(iso) < 10:
return ""
date = iso[:10]
return date if date[4] == "-" and date[7] == "-" else ""
+628
View File
@@ -0,0 +1,628 @@
"""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
+157
View File
@@ -0,0 +1,157 @@
"""Tema claro/oscuro siguiendo el esquema de color del sistema.
Usa el estilo Fusion (consistente entre plataformas) y aplica una paleta clara u
oscura según ``QStyleHints.colorScheme()``. Los widgets que no fuerzan colores
propios (consola de log, fondos) siguen automáticamente esta paleta.
"""
from __future__ import annotations
import subprocess
from collections.abc import Callable
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
# Modos de tema seleccionables por el usuario.
THEME_SYSTEM = "system"
THEME_LIGHT = "light"
THEME_DARK = "dark"
THEME_MODES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
def _portal_is_dark() -> bool | None:
"""Consulta el portal XDG (org.freedesktop.appearance color-scheme).
Devuelve True (1=dark), False (2=light) o None (0=sin preferencia / no disponible).
Es la fuente más fiable en Linux cuando Qt no detecta el esquema.
"""
try:
out = subprocess.run(
[
"gdbus", "call", "--session",
"--dest", "org.freedesktop.portal.Desktop",
"--object-path", "/org/freedesktop/portal/desktop",
"--method", "org.freedesktop.portal.Settings.Read",
"org.freedesktop.appearance", "color-scheme",
],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.SubprocessError):
return None
if out.returncode != 0:
return None
if "uint32 1" in out.stdout:
return True
if "uint32 2" in out.stdout:
return False
return None
def _gsettings_is_dark() -> bool | None:
"""Respaldo: gsettings color-scheme de GNOME ('prefer-dark')."""
try:
out = subprocess.run(
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.SubprocessError):
return None
if out.returncode != 0:
return None
return "dark" in out.stdout.lower()
def system_is_dark(app: QApplication) -> bool:
"""True si el sistema está en modo oscuro.
Prioriza el aviso de Qt; si Qt dice claro (o no soporta la API), consulta el
portal XDG y gsettings, que en sesiones xcb suelen ser más fiables que Qt.
"""
hints = app.styleHints()
if hasattr(hints, "colorScheme"):
try:
if hints.colorScheme() == Qt.ColorScheme.Dark:
return True
except Exception: # noqa: BLE001 - APIs viejas/raras: caer al fallback
pass
for probe in (_portal_is_dark, _gsettings_is_dark):
val = probe()
if val is not None:
return val
return app.palette().color(QPalette.Window).lightness() < 128
def _dark_palette() -> QPalette:
p = QPalette()
window = QColor(0x2b, 0x2b, 0x2b)
base = QColor(0x1e, 0x1e, 0x1e)
alt = QColor(0x35, 0x35, 0x35)
text = QColor(0xdc, 0xdc, 0xdc)
disabled = QColor(0x7f, 0x7f, 0x7f)
highlight = QColor(0x2a, 0x82, 0xda)
p.setColor(QPalette.Window, window)
p.setColor(QPalette.WindowText, text)
p.setColor(QPalette.Base, base)
p.setColor(QPalette.AlternateBase, alt)
p.setColor(QPalette.ToolTipBase, window)
p.setColor(QPalette.ToolTipText, text)
p.setColor(QPalette.Text, text)
p.setColor(QPalette.Button, alt)
p.setColor(QPalette.ButtonText, text)
p.setColor(QPalette.BrightText, Qt.red)
p.setColor(QPalette.Link, highlight)
p.setColor(QPalette.Highlight, highlight)
p.setColor(QPalette.HighlightedText, Qt.black)
p.setColor(QPalette.PlaceholderText, disabled)
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
p.setColor(QPalette.Disabled, role, disabled)
return p
def resolve_is_dark(app: QApplication, mode: str) -> bool:
"""Decide si pintar oscuro según el modo elegido (system/light/dark)."""
if mode == THEME_DARK:
return True
if mode == THEME_LIGHT:
return False
return system_is_dark(app) # THEME_SYSTEM (o valor desconocido)
def _repolish_all(app: QApplication) -> None:
"""Re-poliza todos los widgets para que adopten la nueva paleta en caliente.
``setPalette`` por sí solo no repinta los widgets ya creados: los que tienen
stylesheet (pills) no re-resuelven ``palette(...)`` y algunos fondos base (la
consola de log) no se redibujan. unpolish→polish→update fuerza ese refresco.
"""
for widget in app.allWidgets():
style = widget.style()
style.unpolish(widget)
style.polish(widget)
widget.update()
def apply_theme(app: QApplication, mode: str = THEME_SYSTEM) -> None:
"""Aplica estilo Fusion + paleta clara/oscura según el modo elegido."""
app.setStyle("Fusion")
if resolve_is_dark(app, mode):
app.setPalette(_dark_palette())
else:
app.setPalette(app.style().standardPalette())
_repolish_all(app)
def watch_system_theme(app: QApplication, should_follow: Callable[[], bool]) -> None:
"""Re-aplica el tema al cambiar el esquema del sistema, solo si seguimos al sistema.
``should_follow`` se consulta en cada cambio: devuelve True cuando el modo activo
es 'system' (si el usuario ha forzado claro/oscuro, ignoramos el cambio del SO).
"""
hints = app.styleHints()
if hasattr(hints, "colorSchemeChanged"):
hints.colorSchemeChanged.connect(
lambda _scheme: apply_theme(app, THEME_SYSTEM) if should_follow() else None
)