Files
pyssh-client/app/theme.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00

183 lines
7.0 KiB
Python

"""Themes de l'application : palette d'interface (QSS) + palette du terminal.
Trois themes sont fournis (clair par defaut, bleu nuit, noir). Ils partagent le meme
gabarit `resources/theme.qss`, dont les $variables sont remplacees par la palette
choisie : une seule feuille de style a maintenir au lieu de trois quasi identiques.
"""
from __future__ import annotations
from string import Template
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap
from PySide6.QtWidgets import QApplication
from app.app_settings import get_setting, set_setting
from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe)
STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"
THEME_LIGHT = "light"
THEME_MIDNIGHT = "midnight"
THEME_BLACK = "black"
DEFAULT_THEME = THEME_LIGHT
# ---------------------------------------------------------------------------
# Palettes d'interface
# ---------------------------------------------------------------------------
UI_PALETTES: dict[str, dict[str, str]] = {
THEME_LIGHT: {
"window": "#f4f6f9", "base": "#ffffff", "panel": "#eef1f6", "elevated": "#e4e9f0",
"input": "#ffffff", "hover": "#dfe6f2", "pressed": "#d2dae8",
"border": "#d3dae4", "border_strong": "#b9c3d1",
"text": "#1c2430", "text_dim": "#5c6875", "text_disabled": "#a3adba",
"accent": "#2f6fed", "accent_hover": "#1f5ed6", "on_accent": "#ffffff",
"scroll": "#c3ccd9", "scroll_hover": "#a7b3c4",
},
THEME_MIDNIGHT: {
"window": "#0a1220", "base": "#0c1526", "panel": "#0e1a30", "elevated": "#1c2c47",
"input": "#101c30", "hover": "#172542", "pressed": "#16233b",
"border": "#1c2c47", "border_strong": "#2a3c5c",
"text": "#e6ecf6", "text_dim": "#93a4c2", "text_disabled": "#55627c",
"accent": "#2f6fed", "accent_hover": "#4f8cff", "on_accent": "#ffffff",
"scroll": "#2a3c5c", "scroll_hover": "#3a527d",
},
THEME_BLACK: {
"window": "#000000", "base": "#0a0a0a", "panel": "#141414", "elevated": "#1f1f1f",
"input": "#141414", "hover": "#262626", "pressed": "#0f0f0f",
"border": "#2b2b2b", "border_strong": "#3d3d3d",
"text": "#e8e8e8", "text_dim": "#9a9a9a", "text_disabled": "#5a5a5a",
"accent": "#4a9eff", "accent_hover": "#6fb3ff", "on_accent": "#000000",
"scroll": "#3a3a3a", "scroll_hover": "#525252",
},
}
# ---------------------------------------------------------------------------
# Palettes du terminal (16 couleurs ANSI + defauts + curseur)
# ---------------------------------------------------------------------------
TERMINAL_PALETTES: dict[str, dict] = {
THEME_LIGHT: {
"fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110),
"selection": (47, 111, 237, 70),
"ansi": {
"black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800",
"blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875",
"brightblack": "#8a94a3", "brightred": "#e04b3a", "brightgreen": "#3ca142",
"brightbrown": "#c98a00", "brightblue": "#2f6fed", "brightmagenta": "#a944c7",
"brightcyan": "#0aa2b0", "brightwhite": "#1c2430",
},
},
THEME_MIDNIGHT: {
"fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140),
"selection": (79, 140, 255, 90),
"ansi": {
"black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c",
"blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5",
"brightblack": "#3a4a68", "brightred": "#ff8a9b", "brightgreen": "#a9e6b5",
"brightbrown": "#f5d778", "brightblue": "#7aa9ff", "brightmagenta": "#dcb3f0",
"brightcyan": "#8fe3ec", "brightwhite": "#ffffff",
},
},
THEME_BLACK: {
"fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140),
"selection": (74, 158, 255, 90),
"ansi": {
"black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52",
"blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0",
"brightblack": "#5a5a5a", "brightred": "#ff6b78", "brightgreen": "#a5e075",
"brightbrown": "#e5a663", "brightblue": "#6fb3ff", "brightmagenta": "#d178ee",
"brightcyan": "#5fc9d8", "brightwhite": "#ffffff",
},
},
}
THEME_LABELS = {
THEME_LIGHT: "Clair",
THEME_MIDNIGHT: "Bleu nuit",
THEME_BLACK: "Noir",
}
_current_theme = DEFAULT_THEME
# ---------------------------------------------------------------------------
# Preference persistante
# ---------------------------------------------------------------------------
def load_saved_theme() -> str:
theme = get_setting("theme")
return theme if theme in UI_PALETTES else DEFAULT_THEME
def save_theme(theme: str) -> None:
set_setting("theme", theme)
DEFAULT_FONT_SIZE = 10
def load_font_size() -> int:
try:
size = int(get_setting("font_size", DEFAULT_FONT_SIZE))
except (TypeError, ValueError):
return DEFAULT_FONT_SIZE
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
def save_font_size(size: int) -> None:
set_setting("font_size", size)
def current_theme() -> str:
return _current_theme
def terminal_palette(theme: str | None = None) -> dict:
return TERMINAL_PALETTES.get(theme or _current_theme, TERMINAL_PALETTES[DEFAULT_THEME])
# ---------------------------------------------------------------------------
# Application du theme
# ---------------------------------------------------------------------------
def apply_theme(app: QApplication, theme: str = DEFAULT_THEME) -> None:
"""Bascule sur le style Fusion (necessaire pour que la QSS s'applique
uniformement, y compris aux widgets stylises nativement par Windows) puis
construit la feuille de style a partir de la palette demandee."""
global _current_theme
if theme not in UI_PALETTES:
theme = DEFAULT_THEME
_current_theme = theme
app.setStyle("Fusion")
try:
template = Template(STYLESHEET_PATH.read_text(encoding="utf-8"))
app.setStyleSheet(template.safe_substitute(UI_PALETTES[theme]))
except OSError:
pass
def build_app_icon() -> QIcon:
"""Genere une icone simple (pas de fichier externe requis) : carre + prompt '>_'"""
size = 64
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
gradient = QLinearGradient(0, 0, size, size)
gradient.setColorAt(0, QColor("#182a4a"))
gradient.setColorAt(1, QColor("#0a1220"))
painter.setBrush(gradient)
painter.setPen(QColor("#2a3c5c"))
painter.drawRoundedRect(2, 2, size - 4, size - 4, 14, 14)
font = QFont("Consolas")
font.setBold(True)
font.setPointSize(24)
painter.setFont(font)
painter.setPen(QColor("#4f8cff"))
painter.drawText(pixmap.rect(), Qt.AlignCenter, ">_")
painter.end()
return QIcon(pixmap)