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:
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user