Files
jail-launcher/jail_launcher/ui/game_row.py
T

289 lines
11 KiB
Python

"""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 ""