Consola amb 3 estats (mostra/auto-amaga/amaga), animada i més alta

- Submenú Opcions > Consola: Mostra / Auto-amaga / Amaga, persistit a
  settings.json (console_mode). Es reemplaça el QSplitter per un panell
  col·lapsable amb alçada animada (QPropertyAnimation, easing InOutCubic) i
  més alçada (220px).
- Mode auto: la consola es desplega amb activitat (worker o nova línia de log)
  i es replega sola tras un marge sense activitat.
- Pills robustos al canvi de tema: color de text concret des de la paleta en
  comptes de palette(...) (que Qt cacheja), i pills_box sense fons propi perquè
  no pinti cap banda darrere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:35:37 +02:00
parent 93efbb06c4
commit 962e5b054f
3 changed files with 151 additions and 15 deletions
+10
View File
@@ -18,6 +18,14 @@ def _valid_theme(value) -> str:
return value if value in _THEMES else "system" return value if value in _THEMES else "system"
_CONSOLE_MODES = ("show", "auto", "hide")
def _valid_console_mode(value) -> str:
"""Normaliza el modo de consola; cae a 'show' si es desconocido."""
return value if value in _CONSOLE_MODES else "show"
@dataclass @dataclass
class Settings: class Settings:
hide_not_downloaded: bool = False hide_not_downloaded: bool = False
@@ -25,6 +33,7 @@ class Settings:
gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona) gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona)
check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar
theme: str = "system" # tema de la UI: "system" | "light" | "dark" theme: str = "system" # tema de la UI: "system" | "light" | "dark"
console_mode: str = "show" # consola de log: "show" | "auto" | "hide"
# Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s). # Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s).
git_fetch_timeout: int = 60 # techo para fetch / comprobar update git_fetch_timeout: int = 60 # techo para fetch / comprobar update
git_clone_timeout: int = 900 # techo para clone (repo grande) git_clone_timeout: int = 900 # techo para clone (repo grande)
@@ -51,6 +60,7 @@ def load_settings() -> Settings:
gitea_token=str(data.get("gitea_token", "")), gitea_token=str(data.get("gitea_token", "")),
check_updates_on_start=bool(data.get("check_updates_on_start", False)), check_updates_on_start=bool(data.get("check_updates_on_start", False)),
theme=_valid_theme(data.get("theme", "system")), theme=_valid_theme(data.get("theme", "system")),
console_mode=_valid_console_mode(data.get("console_mode", "show")),
git_fetch_timeout=int(data.get("git_fetch_timeout", 60)), git_fetch_timeout=int(data.get("git_fetch_timeout", 60)),
git_clone_timeout=int(data.get("git_clone_timeout", 900)), git_clone_timeout=int(data.get("git_clone_timeout", 900)),
http_timeout=int(data.get("http_timeout", 15)), http_timeout=int(data.get("http_timeout", 15)),
+19 -4
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from PySide6.QtCore import QRectF, Qt, Signal from PySide6.QtCore import QRectF, Qt, Signal
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -62,15 +63,26 @@ def _rounded_pixmap(src: QPixmap, size: int, radius: int) -> QPixmap:
return out return out
def _make_pill(text: str, fg: str = "palette(text)", bg: str = _NEUTRAL_BG, 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: bold: bool = False) -> QLabel:
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí.""" """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 = QLabel(text)
pill.setAttribute(Qt.WA_TransparentForMouseEvents) pill.setAttribute(Qt.WA_TransparentForMouseEvents)
weight = "600" if bold else "normal" weight = "600" if bold else "normal"
pill.setStyleSheet( pill.setStyleSheet(
f"QLabel {{ background: {bg}; color: {fg}; border-radius: 9px; " f"QLabel {{ background: {bg}; color: {fg or _palette_text_color()}; "
f"padding: 2px 8px; font-size: 11px; font-weight: {weight}; }}" f"border-radius: 9px; padding: 2px 8px; font-size: 11px; "
f"font-weight: {weight}; }}"
) )
return pill return pill
@@ -119,6 +131,9 @@ class GameRow(QFrame):
self.pills_box = QWidget() self.pills_box = QWidget()
self.pills_layout = FlowLayout(self.pills_box, spacing=6) self.pills_layout = FlowLayout(self.pills_box, spacing=6)
self.pills_box.setAttribute(Qt.WA_TransparentForMouseEvents) 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 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
sp.setHeightForWidth(True) sp.setHeightForWidth(True)
self.pills_box.setSizePolicy(sp) self.pills_box.setSizePolicy(sp)
+122 -11
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QThreadPool, Qt, QTimer from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QThreadPool, Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@@ -15,7 +15,6 @@ from PySide6.QtWidgets import (
QPlainTextEdit, QPlainTextEdit,
QProgressBar, QProgressBar,
QScrollArea, QScrollArea,
QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -30,6 +29,14 @@ from .game_row import GameRow
APP_NAME = "Jail Launcher" APP_NAME = "Jail Launcher"
WINDOW_TITLE = f"© 2026 {APP_NAME} — JailDesigner" WINDOW_TITLE = f"© 2026 {APP_NAME} — JailDesigner"
CONSOLE_HEIGHT = 220 # alçada de la consola desplegada (px)
CONSOLE_ANIM_MS = 220 # durada de l'animació de desplegar/replegar
CONSOLE_IDLE_MS = 1800 # marge sense activitat abans de replegar en mode auto
CONSOLE_SHOW = "show"
CONSOLE_AUTO = "auto"
CONSOLE_HIDE = "hide"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self, config: Config, root: Path, parent=None) -> None: def __init__(self, config: Config, root: Path, parent=None) -> None:
@@ -57,7 +64,10 @@ class MainWindow(QMainWindow):
self._build_menu() self._build_menu()
splitter = QSplitter(Qt.Vertical) central = QWidget()
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
# --- Lista de juegos con scroll --- # --- Lista de juegos con scroll ---
list_container = QWidget() list_container = QWidget()
@@ -75,18 +85,18 @@ class MainWindow(QMainWindow):
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scroll.setWidget(list_container) scroll.setWidget(list_container)
splitter.addWidget(scroll) root_layout.addWidget(scroll, stretch=1)
# --- Panel de log --- # --- Panel de log (consola colapsable amb alçada animada) ---
self.log_view = QPlainTextEdit() self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True) self.log_view.setReadOnly(True)
self.log_view.setMaximumBlockCount(5000) self.log_view.setMaximumBlockCount(5000)
self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;") self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;")
splitter.addWidget(self.log_view) self.log_view.setMinimumHeight(0)
splitter.setStretchFactor(0, 3) root_layout.addWidget(self.log_view)
splitter.setStretchFactor(1, 1)
self.setCentralWidget(splitter) self.setCentralWidget(central)
self._setup_console()
# Barra de progrés (cantonada de la status bar) per a la comprovació d'updates; # Barra de progrés (cantonada de la status bar) per a la comprovació d'updates;
# amagada fins que arrenca una comprovació. # amagada fins que arrenca una comprovació.
@@ -118,6 +128,64 @@ class MainWindow(QMainWindow):
stall_time=s.git_stall_time, 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 = QPropertyAnimation(self.log_view, b"maximumHeight", self)
self._console_anim.setDuration(CONSOLE_ANIM_MS)
self._console_anim.setEasingCurve(QEasingCurve.InOutCubic)
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:
if open_ == self._console_open:
return
self._console_open = open_
target = CONSOLE_HEIGHT if open_ else 0
if open_:
self.log_view.show() # visible abans d'animar l'obertura
self._console_anim.stop()
if not animated:
self.log_view.setMaximumHeight(target)
if not open_:
self.log_view.hide()
return
self._console_anim.setStartValue(self.log_view.maximumHeight())
self._console_anim.setEndValue(target)
self._console_anim.start()
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ú # --------------------------------------------------------------- menú
def _build_menu(self) -> None: def _build_menu(self) -> None:
@@ -141,6 +209,7 @@ class MainWindow(QMainWindow):
menu.addSeparator() menu.addSeparator()
self._build_theme_menu(menu) self._build_theme_menu(menu)
self._build_console_menu(menu)
menu.addSeparator() menu.addSeparator()
self.action_delete = QAction("Esborra un joc", self, checkable=True) self.action_delete = QAction("Esborra un joc", self, checkable=True)
@@ -169,6 +238,36 @@ class MainWindow(QMainWindow):
group.addAction(action) group.addAction(action)
submenu.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: def _on_theme_selected(self, mode: str) -> None:
self.settings.theme = mode self.settings.theme = mode
save_settings(self.settings) save_settings(self.settings)
@@ -256,14 +355,26 @@ class MainWindow(QMainWindow):
def _log(self, text: str) -> None: def _log(self, text: str) -> None:
self.log_view.appendPlainText(text) self.log_view.appendPlainText(text)
# 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: def _track(self, worker) -> None:
"""Retiene el worker hasta que emite finished/error, evitando que el GC """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.""" se lleve su objeto de señales antes de entregar la señal en cola."""
worker.setAutoDelete(False) worker.setAutoDelete(False)
self._workers.add(worker) self._workers.add(worker)
worker.signals.finished.connect(lambda *_: self._workers.discard(worker)) self._console_activity_started()
worker.signals.error.connect(lambda *_: self._workers.discard(worker))
def _done(*_):
self._workers.discard(worker)
self._console_activity_maybe_ended()
worker.signals.finished.connect(_done)
worker.signals.error.connect(_done)
# --------------------------------------------------------------- accions # --------------------------------------------------------------- accions