refactor: renombra el paquet a jail_launcher i l'app a «Jail Launcher»
This commit is contained in:
@@ -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 ""
|
||||
Reference in New Issue
Block a user