Estil targeta tipus web a les files + tema seleccionable (system/clar/fosc)

UI:
- Files amb estil de targeta: icona arrodonida, títol gran, subtítol atenuat i
  'pills' amb estat, versió, data de llançament, jugadors, autor i topics. Els
  pills envolten amb un FlowLayout nou quan no caben.
- Submenú Opcions > Tema amb Sistema/Clar/Fosc; persisteix a settings.json
  (theme) i s'aplica a l'instant. El watcher del SO només actua en mode Sistema.

Dades:
- GameMeta guarda topics i created_at, llegits de la resposta de Gitea que ja
  demanàvem (gratis, auto-sincronitzats).
- games.toml: camps opcionals players i author per joc (la resta surt de Gitea).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:13:32 +02:00
parent e9f0098df8
commit e0a93a9c28
10 changed files with 312 additions and 64 deletions
+135 -53
View File
@@ -1,35 +1,82 @@
"""Fila de la llista: la fila sencera actua com un botó.
"""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
Un indicador d'icona a la dreta (passiu) mostra què farà el clic. L'esborrat es
gestiona per fora (mètode a definir); aquest widget només exposa la senyal.
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 Qt, Signal
from PySide6.QtGui import QPalette, QPixmap
from PySide6.QtCore import QRectF, Qt, Signal
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
from PySide6.QtWidgets import (
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 _make_pill(text: str, fg: str = "palette(text)", bg: str = _NEUTRAL_BG,
bold: bool = False) -> QLabel:
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí."""
pill = QLabel(text)
pill.setAttribute(Qt.WA_TransparentForMouseEvents)
weight = "600" if bold else "normal"
pill.setStyleSheet(
f"QLabel {{ background: {bg}; color: {fg}; border-radius: 9px; "
f"padding: 2px 8px; font-size: 11px; font-weight: {weight}; }}"
)
return pill
class GameRow(QFrame):
"""Fila clicable. Emet `activated` en clic i `delete_requested` per al futur esborrat."""
"""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
@@ -43,7 +90,6 @@ class GameRow(QFrame):
self._delete_mode = False
self.setObjectName("gameRow")
self.setFrameShape(QFrame.StyledPanel)
# La fila és un botó: cursor de mà i ressaltat en passar el ratolí.
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
@@ -53,26 +99,34 @@ class GameRow(QFrame):
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(12)
# --- Icona del joc ---
# --- 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)
layout.addWidget(self.icon_label, alignment=Qt.AlignTop)
# --- Text (nom + descripció + estat) ---
# --- Text: títol + descripció + pills (centrat verticalment) ---
text_box = QVBoxLayout()
text_box.setSpacing(2)
text_box.setSpacing(4)
self.name_label = QLabel(game.name)
self.name_label.setStyleSheet("font-weight: bold; font-size: 14px;")
self.name_label.setStyleSheet("font-weight: bold; font-size: 18px;")
self.desc_label = QLabel("")
self.desc_label.setWordWrap(True)
# Text atenuat que segueix la paleta (clar/fosc) en lloc d'un gris fix.
self.desc_label.setForegroundRole(QPalette.PlaceholderText)
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
# 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)
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.status_label)
text_box.addWidget(self.pills_box)
text_box.addStretch(1)
layout.addLayout(text_box, stretch=1)
@@ -82,14 +136,7 @@ class GameRow(QFrame):
self.action_label.setMinimumWidth(96)
layout.addWidget(self.action_label)
# Els fills no intercepten el ratolí: tot clic arriba a la fila.
for child in (
self.icon_label,
self.name_label,
self.desc_label,
self.status_label,
self.action_label,
):
for child in (self.name_label, self.desc_label, self.action_label):
child.setAttribute(Qt.WA_TransparentForMouseEvents)
self.refresh()
@@ -125,11 +172,12 @@ class GameRow(QFrame):
self.refresh()
def refresh(self) -> None:
"""Recarrega icona, descripció i estat des de la cache local."""
"""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._set_status(meta)
self._rebuild_pills(meta)
self._set_action(meta)
def _set_icon(self) -> None:
path = icon_path(self.root, self.game.id)
@@ -139,44 +187,70 @@ class GameRow(QFrame):
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,
)
)
self.icon_label.setPixmap(_rounded_pixmap(pixmap, ICON_SIZE, ICON_RADIUS))
def _set_status(self, meta: GameMeta) -> None:
# ----------------------------------------------------------------- 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()
# Text d'estat (independent del mode esborrar).
# 1) Estat (pill amb color).
if not installed:
self.status_label.setText("No descarregat")
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
fg, bg = _STATUS_COLORS["none"]
self.pills_layout.addWidget(_make_pill("No descarregat", fg, bg, bold=True))
elif self._update_available:
self.status_label.setText("⬆ Actualització disponible")
self.status_label.setStyleSheet(
"color: #e0a030; font-weight: bold; font-size: 11px;"
fg, bg = _STATUS_COLORS["update"]
self.pills_layout.addWidget(
_make_pill("⬆ Actualització", fg, bg, bold=True)
)
else:
ver = f" {meta.version}" if meta.version else ""
self.status_label.setText(f"Descarregat{ver}")
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
fg, bg = _STATUS_COLORS["ok"]
self.pills_layout.addWidget(_make_pill("✓ Descarregat", fg, bg, bold=True))
# Text d'acció (què fa el clic).
# 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:
# Només es pot esborrar el que està descarregat.
self._set_action("Esborra", "#d9534f") if installed else self.action_label.clear()
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("Descarrega", "#4a90d9")
self._set_action_text("Descarrega", "#4a90d9")
elif self._update_available:
self._set_action("Actualitza", "#e0a030")
self._set_action_text("Actualitza", "#e0a030")
else:
self._set_action("Juga", "#6fae6f")
self._set_action_text("Juga", "#6fae6f")
def _set_action(self, text: str, color: str) -> None:
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;"
@@ -187,5 +261,13 @@ class GameRow(QFrame):
def set_busy(self, busy: bool, message: str = "") -> None:
self._busy = busy
if busy and message:
self.status_label.setText(message)
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
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 ""