Files
pyssh-client/app/theme.py
snoopy f537868da3 Confort du terminal, reconnexion, et hotes deja connus de ssh
Zoom de police : Ctrl+molette et Ctrl+ +/-, Ctrl+0 pour revenir a la taille
par defaut. La taille choisie est memorisee entre deux lancements.

Barre de defilement de l'historique. pyte ne sait defiler que par demi-pages :
la barre est donc graduee en pages plutot qu'en lignes, ce qui donne un repere
visuel de position et permet de faire glisser. Maj+PagePrec/Suiv au clavier,
Ctrl+Fin pour revenir a la vue en direct.

Recherche dans l'historique (Ctrl+F) : la barre apparait en bas de l'onglet et
la recherche remonte l'historique page par page jusqu'a trouver, en
selectionnant la correspondance — donc visible et directement copiable.

Reconnexion en un clic : un bouton apparait dans la barre de l'onglet des que
la connexion tombe. La fabrication de la connexion a ete extraite dans
_create_connection(), ce qui permet de la recreer a l'identique sans ressaisir
quoi que ce soit. Utile avec des serveurs de test qui redemarrent souvent.

Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont
desormais reconnus, ce qui evite de reconfirmer une empreinte que votre ssh
connait deja. paramiko ne reecrit jamais ce fichier : les nouvelles cles
continuent d'aller dans celui de l'application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:31:50 +02:00

215 lines
8.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
import json
from pathlib import Path
from string import Template
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient
from PySide6.QtWidgets import QApplication
from app.paths import RESOURCES_DIR, DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"
SETTINGS_FILE = DATA_DIR / "settings.json"
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:
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
theme = data.get("theme")
if theme in UI_PALETTES:
return theme
except (OSError, json.JSONDecodeError):
pass
return DEFAULT_THEME
def save_theme(theme: str) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
data = {}
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data["theme"] = theme
try:
SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
except OSError:
pass
DEFAULT_FONT_SIZE = 10
def load_font_size() -> int:
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
size = int(data.get("font_size", DEFAULT_FONT_SIZE))
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return DEFAULT_FONT_SIZE
def save_font_size(size: int) -> None:
_update_settings("font_size", size)
def _update_settings(key: str, value) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
data = {}
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data[key] = value
try:
SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
except OSError:
pass
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)