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