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.