diff --git a/app/core/config.py b/app/core/config.py index 2a35308..a6854d1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -64,6 +64,10 @@ _DEFAULT: dict[str, Any] = { "auto_connect_on_startup": False, "auto_reconnect": True, "reconnect_interval": 15, + # « dark » ou « light ». Lu une seule fois, au lancement : les feuilles + # de style sont posées dans les constructeurs des fenêtres (cf. + # app/ui/theme.py). Une valeur inconnue retombe sur « dark ». + "theme": "dark", }, } diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 244a1a8..9cf9c59 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -102,7 +102,7 @@ class AdminWindow(QDialog): banner = QLabel(" WGSecure — Configuration Administrateur") banner.setFixedHeight(40) - banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN)) + banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN, theme.TEXT)) layout.addWidget(banner) # Onglets de premier niveau : config qui conditionne la connexion @@ -941,6 +941,18 @@ class AdminWindow(QDialog): lay.addWidget(grp2) + grp3 = QGroupBox("Apparence") + form3 = QFormLayout(grp3) + + self._cmb_theme = QComboBox() + for cle, libelle in theme.PALETTES.items(): + self._cmb_theme.addItem(libelle, cle) + actuel = self._cmb_theme.findData(theme.CURRENT) + self._cmb_theme.setCurrentIndex(actuel if actuel >= 0 else 0) + self._row(form3, "Thème :", self._cmb_theme) + + lay.addWidget(grp3) + btn_apply = QPushButton("✅ Appliquer les paramètres") btn_apply.clicked.connect(self._apply_settings) lay.addWidget(btn_apply) @@ -952,12 +964,26 @@ class AdminWindow(QDialog): self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked()) self._cfg.set("ui", "reconnect_interval", self._spin_interval.value()) self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked()) + # Le thème n'est lu qu'au lancement : les feuilles de style sont + # posées dans les constructeurs des fenêtres, et les repeindre + # supposerait de reconstruire celle qui porte le tunnel. Le dire plutôt + # que de laisser croire à un réglage sans effet. + choisi = self._cmb_theme.currentData() + theme_change = choisi != (self._cfg.get("ui", "theme") or "dark") + self._cfg.set("ui", "theme", choisi) + autostart = self._chk_autostart.isChecked() if autostart != bool(self._cfg.get("ui", "autostart")): self._cfg.set_autostart(autostart) else: self._cfg.save() - QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.") + + message = "Paramètres enregistrés." + if theme_change: + message += ("\n\nLe thème « " + f"{theme.PALETTES.get(choisi, choisi)} » s'appliquera " + "au prochain démarrage de WGSecure.") + QMessageBox.information(self, "Paramètres", message) # ------------------------------------------------------------------ # # Onglet Sécurité @@ -1579,13 +1605,11 @@ class AdminWindow(QDialog): v.addWidget(btn_close) dlg.exec() - #: Rendu d'une étape de diagnostic : puce, couleur du libellé. - _STEP_STYLE = { - "ok": ("✅", theme.OK_TEXT), - "warn": ("⚠️", theme.WARN_TEXT), - "fail": ("❌", theme.FAIL_TEXT), - "skip": ("⏭️", theme.SKIP_TEXT), - } + #: Puce d'une étape de diagnostic. La couleur ne figure pas ici : évaluée + #: à l'import comme le reste d'un attribut de classe, elle aurait retenu + #: les teintes du thème d'alors. Elle est demandée au thème à l'affichage + #: (cf. `theme.state_text`). + _STEP_BULLET = {"ok": "✅", "warn": "⚠️", "fail": "❌", "skip": "⏭️"} def _run_test(self): if self._diag_worker is not None: @@ -1628,7 +1652,11 @@ class AdminWindow(QDialog): # chaque cellule renvoyant à la ligne. rows = [''] for st in steps: - bullet, color = self._STEP_STYLE.get(st["state"], ("•", theme.TEXT)) + if st["state"] in self._STEP_BULLET: + bullet = self._STEP_BULLET[st["state"]] + color = theme.state_text(st["state"]) + else: + bullet, color = "•", theme.TEXT weight = "bold" if st["state"] == "fail" else "normal" rows.append( '' diff --git a/app/ui/bw_graph.py b/app/ui/bw_graph.py index 3b02fea..afeb4ff 100644 --- a/app/ui/bw_graph.py +++ b/app/ui/bw_graph.py @@ -5,6 +5,8 @@ from PyQt6.QtWidgets import QWidget from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath from PyQt6.QtCore import Qt, QRect, QPointF +from app.ui import theme + def _fmt(bps: float) -> str: if bps >= 1_048_576: @@ -43,7 +45,7 @@ class BandwidthGraph(QWidget): pad_l, pad_r, pad_t, pad_b = 42, 8, 6, 20 # Fond - p.fillRect(0, 0, w, h, QColor("#17202a")) + p.fillRect(0, 0, w, h, QColor(theme.GRAPH_BG)) plot_w = w - pad_l - pad_r plot_h = h - pad_t - pad_b @@ -71,18 +73,18 @@ class BandwidthGraph(QWidget): line.lineTo(QPointF(x, y)) p.drawPath(line) - _path(self._rx, "#5dade2", "#1a3a52") # RX bleu - _path(self._tx, "#58d68d", "#1a3d2b") # TX vert + _path(self._rx, theme.GRAPH_RX, theme.GRAPH_RX_FILL) # RX + _path(self._tx, theme.GRAPH_TX, theme.GRAPH_TX_FILL) # TX # Axes - p.setPen(QPen(QColor("#2e4057"))) + p.setPen(QPen(QColor(theme.GRAPH_AXIS))) p.drawLine(pad_l, pad_t, pad_l, pad_t + plot_h) p.drawLine(pad_l, pad_t + plot_h, w - pad_r, pad_t + plot_h) # Labels Y font = QFont("Arial", 8) p.setFont(font) - p.setPen(QPen(QColor("#5d6d7e"))) + p.setPen(QPen(QColor(theme.GRAPH_LABEL))) for frac, label in [(0.0, _fmt(max_val)), (0.5, _fmt(max_val / 2)), (1.0, "0")]: y = int(pad_t + frac * plot_h) p.drawText(QRect(0, y - 8, pad_l - 2, 16), @@ -90,9 +92,9 @@ class BandwidthGraph(QWidget): label) # Légende - p.setPen(QPen(QColor("#5dade2"))) + p.setPen(QPen(QColor(theme.GRAPH_RX))) p.drawText(pad_l + 4, pad_t + 12, f"↓ {_fmt(self._rx[-1])}") - p.setPen(QPen(QColor("#58d68d"))) + p.setPen(QPen(QColor(theme.GRAPH_TX))) p.drawText(pad_l + 90, pad_t + 12, f"↑ {_fmt(self._tx[-1])}") p.end() diff --git a/app/ui/history_dialog.py b/app/ui/history_dialog.py index 5369caf..c523a90 100644 --- a/app/ui/history_dialog.py +++ b/app/ui/history_dialog.py @@ -10,7 +10,13 @@ from PyQt6.QtGui import QColor, QFont from app.core import history as hist from app.ui import theme -_CSS = f""" +def _css() -> str: + """Feuille de style de la fenêtre d'historique. + + Fonction et non constante : bâtie à l'import, elle retenait les couleurs + du thème chargé à ce moment-là et ignorait celui appliqué au démarrage. + """ + return f""" QDialog {{ background: {theme.BG_SUNKEN}; }} QLabel {{ color: {theme.TEXT}; background: transparent; }} QTableWidget {{ @@ -21,12 +27,14 @@ _CSS = f""" background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px; border: none; font-weight: bold; font-size: 11px; }} - QTableWidget::item:selected {{ background: {theme.ACCENT}; }} + QTableWidget::item:selected {{ background: {theme.ACCENT}; + color: {theme.ON_SOLID}; }} QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px; padding: 6px 14px; border: none; }} - QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }} + QPushButton:hover {{ background: {theme.ACCENT_HOVER}; + color: {theme.ON_SOLID}; }} """ @@ -35,7 +43,7 @@ class HistoryDialog(QDialog): super().__init__(parent) self.setWindowTitle("WGSecure — Historique des sessions") self.setMinimumSize(680, 420) - self.setStyleSheet(_CSS) + self.setStyleSheet(_css()) self._build_ui() self._load() @@ -64,7 +72,7 @@ class HistoryDialog(QDialog): self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self._table.setAlternatingRowColors(True) self._table.setStyleSheet( - _CSS + + _css() + f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}" ) layout.addWidget(self._table) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index a2a03d0..73a074b 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -553,9 +553,9 @@ class MainWindow(QMainWindow): self._btn_connect = QPushButton("Se connecter") self._btn_connect.setFixedHeight(44) self._btn_connect.setStyleSheet( - f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;" + f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" - "QPushButton:hover { background: #27ae60; }" + f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}" f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}" ) self._btn_connect.clicked.connect(self._on_connect) @@ -690,14 +690,16 @@ class MainWindow(QMainWindow): def _style_access_button(self, expected: bool): """Met le bouton en avant seulement quand un code est réellement dû.""" - bg, hover = ((theme.WARN_SOLID, "#d68910") if expected - else (theme.BG_RAISED, theme.ACCENT_HOVER)) + bg, hover, fg = ((theme.WARN_SOLID, theme.WARN_HOVER, theme.ON_SOLID) + if expected + else (theme.BG_RAISED, theme.ACCENT_HOVER, theme.TEXT)) weight = "bold" if expected else "normal" self._btn_access.setStyleSheet( - f"QPushButton {{ background: {bg}; color: {theme.TEXT};" + f"QPushButton {{ background: {bg}; color: {fg};" f" font-size: 12px; font-weight: {weight}; border-radius: 6px;" " border: none; }" - f"QPushButton:hover {{ background: {hover}; }}" + f"QPushButton:hover {{ background: {hover};" + f" color: {theme.ON_SOLID}; }}" ) def _sync_window_height(self): @@ -1085,9 +1087,9 @@ class MainWindow(QMainWindow): ) self._btn_connect.setText("Se déconnecter") self._btn_connect.setStyleSheet( - f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;" + f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" - "QPushButton:hover { background: #c0392b; }" + f"QPushButton:hover {{ background: {theme.FAIL_SOLID}; }}" ) self._info_labels["rx_tx"].setText( f"↓ {info.get('rx_bytes','—')} ↑ {info.get('tx_bytes','—')}" @@ -1109,9 +1111,9 @@ class MainWindow(QMainWindow): ) self._btn_connect.setText("Se connecter") self._btn_connect.setStyleSheet( - f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;" + f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" - "QPushButton:hover { background: #27ae60; }" + f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}" ) self._info_labels["rx_tx"].setText("—") self._info_labels["handshake"].setText("—") diff --git a/app/ui/theme.py b/app/ui/theme.py index 96cce64..8f0e0be 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -5,77 +5,308 @@ distinctes dans le seul panneau d'administration, cinq bandeaux d'onglets sans rapport de teinte entre eux, et des blocs de résultat dont le fond ne correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe désormais par ce module — une couleur ne se choisit plus au cas par cas. + +Deux palettes cohabitent. Les noms exportés — `BG`, `TEXT`, `TAB_CSS`… — ne +changent pas : `apply()` les rebranche sur la palette choisie au démarrage, +et les cent soixante-dix appels répartis dans l'interface continuent de lire +`theme.BG` sans rien savoir du thème actif. C'est ce qui permet d'ajouter une +palette sans toucher à un seul widget. + +Le choix est lu une fois, au lancement (cf. `main.py`) : les feuilles de style +sont posées dans les constructeurs des fenêtres, et une bascule à chaud +demanderait de les reconstruire — celle qui porte l'état du tunnel comprise. """ -# ── Fonds ──────────────────────────────────────────────────────────────────── -BG = "#1c2833" # fond de l'application et de toutes les pages -BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code -BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires -BORDER = "rgba(255,255,255,0.18)" -BORDER_SOFT = "rgba(255,255,255,0.10)" -INPUT_BG = "rgba(255,255,255,0.10)" # fond des champs de saisie et listes +# ── Palettes ──────────────────────────────────────────────────────────────── +# Chaque palette porte les mêmes clés ; ce sont elles que `apply()` publie +# comme variables de module. Tout ce qui dépend du thème vit ici, y compris +# ce qui était auparavant écrit au fil des feuilles de style ci-dessous — +# une valeur en dur dans `TAB_CSS` aurait été un fond blanc translucide posé +# sur un thème clair, c'est-à-dire rien du tout. -# ── Textes ─────────────────────────────────────────────────────────────────── -TEXT = "white" -TEXT_MUTED = "rgba(255,255,255,0.70)" -TEXT_FAINT = "rgba(255,255,255,0.50)" -TEXT_CODE = "#a8d8ea" # texte à chasse fixe (aperçus de config, rapports) +_DARK = { + # Fonds + "BG": "#1c2833", # fond de l'application et de toutes les pages + "BG_SUNKEN": "#17202a", # zones en creux : configurations, blocs de code + "BG_RAISED": "#253545", # posé sur le fond : listes, en-têtes secondaires + "BORDER": "rgba(255,255,255,0.18)", + "BORDER_SOFT": "rgba(255,255,255,0.10)", + "INPUT_BG": "rgba(255,255,255,0.10)", # champs de saisie et listes + # Variantes d'état des champs et boutons, jusqu'ici en dur dans TAB_CSS. + "INPUT_BG_READONLY": "rgba(255,255,255,0.06)", + "INPUT_BG_DISABLED": "rgba(255,255,255,0.04)", + "SPIN_BUTTON_BG": "rgba(255,255,255,0.15)", + "BUTTON_DISABLED_BG": "rgba(255,255,255,0.08)", -# ── Accent ─────────────────────────────────────────────────────────────────── -ACCENT = "#2471a3" -ACCENT_HOVER = "#1a5276" -ACCENT_ACTIVE = "#154360" -ACCENT_LIGHT = "#5dade2" + # Textes + "TEXT": "white", + "TEXT_MUTED": "rgba(255,255,255,0.70)", + "TEXT_FAINT": "rgba(255,255,255,0.50)", + "TEXT_CODE": "#a8d8ea", # chasse fixe (aperçus de config, rapports) + # Texte posé sur un aplat saturé — bouton plein, onglet actif, bandeau. + # Distinct de TEXT : sur un thème clair, TEXT est presque noir et + # deviendrait illisible sur un fond d'accent. + "ON_SOLID": "white", + "BANNER_TEXT": "white", -# ── États ──────────────────────────────────────────────────────────────────── -# Chaque état porte un trio : teinte du texte, fond translucide, bouton plein. -OK_TEXT, OK_BG, OK_SOLID = "#a9dfbf", "rgba(39,174,96,0.18)", "#1e8449" -WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b" -FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b" -SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)" + # Accent + "ACCENT": "#2471a3", + "ACCENT_HOVER": "#1a5276", + "ACCENT_ACTIVE": "#154360", + "ACCENT_LIGHT": "#5dade2", + # Fond de l'encart d'introduction : l'accent très dilué. + "HINT_BG": "rgba(36,113,163,0.16)", -# Teintes de survol des boutons pleins ok/fail — reprises telles quelles à -# plusieurs endroits (panneau admin, fenêtre principale) : un seul nom leur -# évite de dériver l'une de l'autre au fil des modifications. -OK_HOVER = "#27ae60" -FAIL_HOVER = "#e74c3c" + # États — chacun porte un trio : texte, fond translucide, bouton plein. + "OK_TEXT": "#a9dfbf", "OK_BG": "rgba(39,174,96,0.18)", "OK_SOLID": "#1e8449", + "WARN_TEXT": "#f7dc6f", "WARN_BG": "rgba(241,196,15,0.15)", "WARN_SOLID": "#b7950b", + "FAIL_TEXT": "#f1948a", "FAIL_BG": "rgba(231,76,60,0.18)", "FAIL_SOLID": "#c0392b", + "SKIP_TEXT": "rgba(255,255,255,0.45)", + "SKIP_BG": "rgba(255,255,255,0.05)", + # Survols des boutons pleins — nommés pour ne pas dériver l'un de l'autre. + "OK_HOVER": "#27ae60", + "WARN_HOVER": "#d68910", + "FAIL_HOVER": "#e74c3c", -_STATE = { - "ok": (OK_TEXT, OK_BG), - "warn": (WARN_TEXT, WARN_BG), - "fail": (FAIL_TEXT, FAIL_BG), - "skip": (SKIP_TEXT, SKIP_BG), - "idle": (TEXT_MUTED, "transparent"), + # Badge de ping (fenêtre principale). Teintes propres, volontairement plus + # tranchées que les aplats d'état : le badge est petit et lu d'un coup. + "PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white", + "PING_MEDIUM_BG": "#d4ac0d", "PING_MEDIUM_FG": "black", + "PING_BAD_BG": "#922b21", "PING_BAD_FG": "white", + + # Graphe de bande passante : fond, puis trait et remplissage par série. + "GRAPH_BG": "#17202a", + "GRAPH_RX": "#5dade2", "GRAPH_RX_FILL": "#1a3a52", + "GRAPH_TX": "#58d68d", "GRAPH_TX_FILL": "#1a3d2b", + "GRAPH_AXIS": "#2e4057", "GRAPH_LABEL": "#5d6d7e", + + # Bandeaux d'onglets. Six teintes calées sur la même luminosité et la même + # saturation : les onglets restent distinguables sans que l'un paraisse + # plus clair ou plus saturé que les autres. + "BANNER": { + "wireguard": "#1a5276", + "keys": "#1a4a6e", + "test": "#1a4a7a", + "settings": "#155e52", + "security": "#7a3b1a", + "about": "#243b53", + }, } -# ── Badge de ping (fenêtre principale) ────────────────────────────────────── -PING_CSS = { - "good": "background:#1e8449; color:white;", - "medium": "background:#d4ac0d; color:black;", - "bad": "background:#922b21; color:white;", - "offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};", +# Le thème clair n'est pas l'inverse du sombre : inverser une luminance donne +# des aplats criards et des textes d'état illisibles. Les fonds descendent +# vers un gris très pâle plutôt que vers le blanc pur — un blanc plein fait +# vibrer les bordures et fatigue sur une fenêtre qui reste ouverte la journée. +# Les teintes d'état, elles, sont assombries : `#a9dfbf` est un vert lisible +# sur ardoise et invisible sur papier. +# +# L'accent, les bandeaux et les aplats pleins ne bougent pas : ils portent +# déjà un texte blanc dans les deux thèmes, et les garder identiques donne à +# l'application la même identité d'un thème à l'autre. +_LIGHT = { + "BG": "#f4f6f8", + "BG_SUNKEN": "#e9edf1", + "BG_RAISED": "#ffffff", + "BORDER": "rgba(28,40,51,0.22)", + "BORDER_SOFT": "rgba(28,40,51,0.12)", + "INPUT_BG": "#ffffff", + "INPUT_BG_READONLY": "rgba(28,40,51,0.05)", + "INPUT_BG_DISABLED": "rgba(28,40,51,0.03)", + "SPIN_BUTTON_BG": "rgba(28,40,51,0.10)", + "BUTTON_DISABLED_BG": "rgba(28,40,51,0.08)", + + "TEXT": "#1c2833", # l'ardoise du thème sombre, passée au texte + "TEXT_MUTED": "rgba(28,40,51,0.72)", + "TEXT_FAINT": "rgba(28,40,51,0.50)", + "TEXT_CODE": "#1a5276", + "ON_SOLID": "white", + "BANNER_TEXT": "white", + + "ACCENT": "#2471a3", + "ACCENT_HOVER": "#1a5276", + "ACCENT_ACTIVE": "#154360", + # Plus soutenu que dans le thème sombre : `#5dade2` sert aussi de couleur + # de texte au journal, et se perd sur un fond pâle. + "ACCENT_LIGHT": "#2e86c1", + "HINT_BG": "rgba(36,113,163,0.10)", + + "OK_TEXT": "#1a6b3c", "OK_BG": "rgba(39,174,96,0.14)", "OK_SOLID": "#1e8449", + "WARN_TEXT": "#8a6d0b", "WARN_BG": "rgba(241,196,15,0.22)", "WARN_SOLID": "#b7950b", + "FAIL_TEXT": "#a5281b", "FAIL_BG": "rgba(231,76,60,0.12)", "FAIL_SOLID": "#c0392b", + "SKIP_TEXT": "rgba(28,40,51,0.45)", + "SKIP_BG": "rgba(28,40,51,0.05)", + "OK_HOVER": "#27ae60", + "WARN_HOVER": "#d68910", + "FAIL_HOVER": "#e74c3c", + + "PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white", + "PING_MEDIUM_BG": "#b7950b", "PING_MEDIUM_FG": "white", + "PING_BAD_BG": "#922b21", "PING_BAD_FG": "white", + + # Le graphe garde son fond en creux, pâle comme le reste ; les deux séries + # sont assombries pour rester lisibles dessus, et leur remplissage devient + # un voile de la même teinte au lieu d'un aplat sombre. + "GRAPH_BG": "#e9edf1", + "GRAPH_RX": "#2e86c1", "GRAPH_RX_FILL": "#cfe3f2", + "GRAPH_TX": "#1e8449", "GRAPH_TX_FILL": "#d3efdd", + "GRAPH_AXIS": "#c3ccd6", "GRAPH_LABEL": "#5d6d7e", + + "BANNER": { + "wireguard": "#1a5276", + "keys": "#1a4a6e", + "test": "#1a4a7a", + "settings": "#155e52", + "security": "#7a3b1a", + "about": "#243b53", + }, } -# ── Entrées de journal (fenêtre principale) ───────────────────────────────── -LOG_COLORS = { - "success": OK_TEXT, - "error": FAIL_TEXT, - "warning": WARN_TEXT, - "info": ACCENT_LIGHT, -} +#: Palettes proposées, dans l'ordre où l'interface les présente. +PALETTES = {"dark": "Sombre", "light": "Clair"} +_PALETTES = {"dark": _DARK, "light": _LIGHT} -# ── Bandeaux d'onglets ─────────────────────────────────────────────────────── -# Six teintes calées sur la même luminosité et la même saturation : les -# onglets restent distinguables sans que l'un paraisse plus clair ou plus -# saturé que les autres, ce qui était le cas des valeurs choisies à la main. -BANNER = { - "wireguard": "#1a5276", - "keys": "#1a4a6e", - "test": "#1a4a7a", - "settings": "#155e52", - "security": "#7a3b1a", - "about": "#243b53", -} +#: Palette active. Renseignée par `apply()`, lue par le panneau de réglages. +CURRENT = "dark" + + +def apply(name: str) -> str: + """Rend `name` active et recalcule tout ce qui en dépend. + + Rend le nom réellement appliqué : un réglage inconnu — configuration + écrite à la main, retour à une version antérieure — retombe sur le thème + sombre plutôt que de faire échouer le démarrage de l'interface. + + À appeler avant de construire la moindre fenêtre : les feuilles de style + sont posées dans les constructeurs et ne sont pas relues ensuite. + """ + global CURRENT + CURRENT = name if name in _PALETTES else "dark" + globals().update(_PALETTES[CURRENT]) + _derive() + return CURRENT + + +def _derive() -> None: + """Recompose les valeurs dérivées de la palette qui vient d'être publiée. + + Les fonctions du module lisent les jetons à l'appel et suivent donc + d'elles-mêmes ; seules les constantes calculées une fois à l'import — + feuilles de style complètes et tables d'état — doivent être refaites. + """ + globals().update( + _STATE={ + "ok": (OK_TEXT, OK_BG), + "warn": (WARN_TEXT, WARN_BG), + "fail": (FAIL_TEXT, FAIL_BG), + "skip": (SKIP_TEXT, SKIP_BG), + "idle": (TEXT_MUTED, "transparent"), + }, + PING_CSS={ + "good": f"background:{PING_GOOD_BG}; color:{PING_GOOD_FG};", + "medium": f"background:{PING_MEDIUM_BG}; color:{PING_MEDIUM_FG};", + "bad": f"background:{PING_BAD_BG}; color:{PING_BAD_FG};", + "offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};", + }, + LOG_COLORS={ + "success": OK_TEXT, + "error": FAIL_TEXT, + "warning": WARN_TEXT, + "info": ACCENT_LIGHT, + }, + TAB_CSS=f""" + QWidget {{ background-color: {BG}; color: {TEXT}; }} + QLabel {{ color: {TEXT}; background: transparent; }} + QCheckBox {{ color: {TEXT}; background: transparent; }} + QGroupBox {{ + color: {TEXT}; + background: transparent; + border: 1px solid {BORDER}; + border-radius: 5px; + margin-top: 10px; + font-weight: bold; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: {TEXT}; + }} + QLineEdit {{ + background: {INPUT_BG}; + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 4px 6px; + }} + QLineEdit:read-only {{ background: {INPUT_BG_READONLY}; }} + QLineEdit:disabled {{ background: {INPUT_BG_DISABLED}; + color: {TEXT_FAINT}; }} + QSpinBox {{ + background: {INPUT_BG}; + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 3px 6px; + }} + QSpinBox::up-button, QSpinBox::down-button {{ + background: {SPIN_BUTTON_BG}; + }} + QComboBox {{ + background: {INPUT_BG}; + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 3px 8px; + }} + QComboBox::drop-down {{ border: none; }} + QComboBox QAbstractItemView {{ + background: {BG_RAISED}; + color: {TEXT}; + selection-background-color: {ACCENT}; + selection-color: {ON_SOLID}; + }} + QTextEdit {{ + background: {BG_SUNKEN}; + color: {TEXT_CODE}; + border: 1px solid {BORDER}; + border-radius: 4px; + }} + QPushButton {{ + background: {ACCENT}; + color: {ON_SOLID}; + border-radius: 5px; + padding: 7px 12px; + border: none; + }} + QPushButton:hover {{ background: {ACCENT_HOVER}; }} + QPushButton:checked {{ background: {ACCENT_ACTIVE}; }} + QPushButton:disabled {{ background: {BUTTON_DISABLED_BG}; + color: {TEXT_FAINT}; }} +""", + TABBAR_CSS=f""" + QTabWidget::pane {{ border: none; background: {BG}; }} + QTabBar::tab {{ + background: {BG_RAISED}; + color: {TEXT_MUTED}; + padding: 6px 14px; + border: none; + }} + QTabBar::tab:selected {{ background: {ACCENT}; color: {ON_SOLID}; }} + QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER}; + color: {ON_SOLID}; }} +""", + ) + + +def state_text(state: str) -> str: + """Couleur de texte d'un état, pour un rendu qui se passe de fond. + + Le panneau de diagnostic tenait sa propre table état → couleur, bâtie à + l'import de son module : elle gardait donc les teintes du thème chargé à + ce moment-là, quel que soit celui appliqué ensuite. La question revient + ici, où la réponse suit la palette active. + """ + return _STATE.get(state, _STATE["idle"])[0] def result_style(state: str, font_size: int = 11) -> str: @@ -116,109 +347,40 @@ def scrollarea_style() -> str: ) -def banner_style(color: str) -> str: - return (f"background: {color}; color: {TEXT}; font-size: 13px;" - " font-weight: bold; padding-left: 8px;") +def banner_style(color: str, fg: str = "") -> str: + """Bandeau de titre posé en tête d'une fenêtre ou d'une page. + + `fg` vaut le blanc des bandeaux d'onglets, qui sont des aplats saturés. + Le bandeau de titre du panneau d'administration, lui, prend le fond en + creux de la fenêtre : en thème clair, c'est un gris très pâle sur lequel + du blanc ne se voit pas. Il passe donc la couleur de texte ordinaire. + """ + return (f"background: {color}; color: {fg or BANNER_TEXT};" + " font-size: 13px; font-weight: bold; padding-left: 8px;") -#: CSS sombre commun à toutes les pages. -TAB_CSS = f""" - QWidget {{ background-color: {BG}; color: {TEXT}; }} - QLabel {{ color: {TEXT}; background: transparent; }} - QCheckBox {{ color: {TEXT}; background: transparent; }} - QGroupBox {{ - color: {TEXT}; - background: transparent; - border: 1px solid {BORDER}; - border-radius: 5px; - margin-top: 10px; - font-weight: bold; - }} - QGroupBox::title {{ - subcontrol-origin: margin; - left: 10px; - padding: 0 4px; - color: {TEXT}; - }} - QLineEdit {{ - background: {INPUT_BG}; - color: {TEXT}; - border: 1px solid {BORDER}; - border-radius: 4px; - padding: 4px 6px; - }} - QLineEdit:read-only {{ background: rgba(255,255,255,0.06); }} - QLineEdit:disabled {{ background: rgba(255,255,255,0.04); - color: {TEXT_FAINT}; }} - QSpinBox {{ - background: {INPUT_BG}; - color: {TEXT}; - border: 1px solid {BORDER}; - border-radius: 4px; - padding: 3px 6px; - }} - QSpinBox::up-button, QSpinBox::down-button {{ - background: rgba(255,255,255,0.15); - }} - QComboBox {{ - background: {INPUT_BG}; - color: {TEXT}; - border: 1px solid {BORDER}; - border-radius: 4px; - padding: 3px 8px; - }} - QComboBox::drop-down {{ border: none; }} - QComboBox QAbstractItemView {{ - background: {BG_RAISED}; - color: {TEXT}; - selection-background-color: {ACCENT}; - }} - QTextEdit {{ - background: {BG_SUNKEN}; - color: {TEXT_CODE}; - border: 1px solid {BORDER}; - border-radius: 4px; - }} - QPushButton {{ - background: {ACCENT}; - color: {TEXT}; - border-radius: 5px; - padding: 7px 12px; - border: none; - }} - QPushButton:hover {{ background: {ACCENT_HOVER}; }} - QPushButton:checked {{ background: {ACCENT_ACTIVE}; }} - QPushButton:disabled {{ background: rgba(255,255,255,0.08); - color: {TEXT_FAINT}; }} -""" +def primary_button_style(solid: str = "", hover: str = "") -> str: + """Bouton d'action principale d'une page (lancer un test, par exemple). -#: Barre d'onglets, utilisée par le panneau principal et les sous-onglets. -TABBAR_CSS = f""" - QTabWidget::pane {{ border: none; background: {BG}; }} - QTabBar::tab {{ - background: {BG_RAISED}; - color: {TEXT_MUTED}; - padding: 6px 14px; - border: none; - }} - QTabBar::tab:selected {{ background: {ACCENT}; color: {TEXT}; }} - QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER}; - color: {TEXT}; }} -""" - - -def primary_button_style(solid: str = OK_SOLID, hover: str = OK_HOVER) -> str: - """Bouton d'action principale d'une page (lancer un test, par exemple).""" - return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};" + Les valeurs par défaut sont résolues à l'appel, et non à la définition : + prises dans la signature, elles auraient figé les teintes de la palette + active au moment de l'import — soit toujours celles du thème sombre. + """ + solid = solid or OK_SOLID + hover = hover or OK_HOVER + return (f"QPushButton {{ padding: 9px; background: {solid};" + f" color: {ON_SOLID};" f" border-radius: 5px; font-weight: bold; border: none; }}" f"QPushButton:hover {{ background: {hover}; }}") def secondary_button_style() -> str: """Bouton d'action secondaire d'une page (à côté d'une action principale).""" - return (f"QPushButton {{ padding: 9px; background: {BG_RAISED}; color: {TEXT};" + return (f"QPushButton {{ padding: 9px; background: {BG_RAISED};" + f" color: {TEXT};" f" border-radius: 5px; border: none; }}" - f"QPushButton:hover {{ background: {ACCENT_HOVER}; }}") + f"QPushButton:hover {{ background: {ACCENT_HOVER};" + f" color: {ON_SOLID}; }}") def hint_style() -> str: @@ -227,7 +389,13 @@ def hint_style() -> str: Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein. """ - return (f"background: rgba(36,113,163,0.16);" + return (f"background: {HINT_BG};" f" border-left: 3px solid {ACCENT_LIGHT};" f" border-radius: 4px; padding: 9px 11px;" f" color: {TEXT_MUTED}; font-size: 11px;") + + +# Palette par défaut publiée dès l'import : le module reste utilisable sans +# initialisation explicite, et `main.py` n'a qu'à rappeler `apply()` avec le +# réglage lu en configuration. +apply("dark") diff --git a/main.py b/main.py index ac11d3c..7bf5b53 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ from app.core import log as conn_log from app.core import shutdown as shutdown_guard from app.core.single_instance import SingleInstanceGuard from app.ui.main_window import MainWindow +from app.ui import theme from app.ui.systray import SystemTray from app.ui import icons from app.utils.platform_utils import install_desktop_entry @@ -112,6 +113,13 @@ def main(): config = Config() + # Avant la moindre fenêtre : les feuilles de style sont posées dans les + # constructeurs, et une palette changée après coup n'atteindrait plus + # rien. `apply` retombe seule sur le thème sombre si le réglage est + # inconnu — une configuration écrite à la main ne doit pas empêcher + # l'application de s'ouvrir. + theme.apply(config.get("ui", "theme") or "dark") + # Reste d'une session précédente mal terminée : DNS détourné vers un # résolveur mort. À faire avant d'ouvrir la fenêtre pour rendre l'accès # au réseau tout de suite.