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