From 962e5b054f25d65a28460467d87586dec7f6d3c4 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 30 May 2026 10:35:37 +0200 Subject: [PATCH] =?UTF-8?q?Consola=20amb=203=20estats=20(mostra/auto-amaga?= =?UTF-8?q?/amaga),=20animada=20i=20m=C3=A9s=20alta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- jlauncher/settings.py | 10 +++ jlauncher/ui/game_row.py | 23 +++++-- jlauncher/ui/main_window.py | 133 +++++++++++++++++++++++++++++++++--- 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/jlauncher/settings.py b/jlauncher/settings.py index 7c53025..c342503 100644 --- a/jlauncher/settings.py +++ b/jlauncher/settings.py @@ -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)), diff --git a/jlauncher/ui/game_row.py b/jlauncher/ui/game_row.py index 3de7e2f..af6b1b1 100644 --- a/jlauncher/ui/game_row.py +++ b/jlauncher/ui/game_row.py @@ -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) diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py index a39a4d1..d5bff34 100644 --- a/jlauncher/ui/main_window.py +++ b/jlauncher/ui/main_window.py @@ -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