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"
_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
class Settings:
hide_not_downloaded: bool = False
@@ -25,6 +33,7 @@ class Settings:
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
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).
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
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", "")),
check_updates_on_start=bool(data.get("check_updates_on_start", False)),
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_clone_timeout=int(data.get("git_clone_timeout", 900)),
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.QtGui import QPainter, QPainterPath, QPalette, QPixmap
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -62,15 +63,26 @@ def _rounded_pixmap(src: QPixmap, size: int, radius: int) -> QPixmap:
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:
"""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.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}; }}"
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
@@ -119,6 +131,9 @@ class GameRow(QFrame):
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)
+122 -11
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
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.QtWidgets import (
QApplication,
@@ -15,7 +15,6 @@ from PySide6.QtWidgets import (
QPlainTextEdit,
QProgressBar,
QScrollArea,
QSplitter,
QVBoxLayout,
QWidget,
)
@@ -30,6 +29,14 @@ from .game_row import GameRow
APP_NAME = "Jail Launcher"
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):
def __init__(self, config: Config, root: Path, parent=None) -> None:
@@ -57,7 +64,10 @@ class MainWindow(QMainWindow):
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 ---
list_container = QWidget()
@@ -75,18 +85,18 @@ class MainWindow(QMainWindow):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
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.setReadOnly(True)
self.log_view.setMaximumBlockCount(5000)
self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;")
splitter.addWidget(self.log_view)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
self.log_view.setMinimumHeight(0)
root_layout.addWidget(self.log_view)
self.setCentralWidget(splitter)
self.setCentralWidget(central)
self._setup_console()
# Barra de progrés (cantonada de la status bar) per a la comprovació d'updates;
# amagada fins que arrenca una comprovació.
@@ -118,6 +128,64 @@ class MainWindow(QMainWindow):
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ú
def _build_menu(self) -> None:
@@ -141,6 +209,7 @@ class MainWindow(QMainWindow):
menu.addSeparator()
self._build_theme_menu(menu)
self._build_console_menu(menu)
menu.addSeparator()
self.action_delete = QAction("Esborra un joc", self, checkable=True)
@@ -169,6 +238,36 @@ class MainWindow(QMainWindow):
group.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:
self.settings.theme = mode
save_settings(self.settings)
@@ -256,14 +355,26 @@ class MainWindow(QMainWindow):
def _log(self, text: str) -> None:
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:
"""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."""
worker.setAutoDelete(False)
self._workers.add(worker)
worker.signals.finished.connect(lambda *_: self._workers.discard(worker))
worker.signals.error.connect(lambda *_: self._workers.discard(worker))
self._console_activity_started()
def _done(*_):
self._workers.discard(worker)
self._console_activity_maybe_ended()
worker.signals.finished.connect(_done)
worker.signals.error.connect(_done)
# --------------------------------------------------------------- accions