"""Jetons de couleur et fragments de feuille de style partagés. Les couleurs étaient écrites en dur au fil des widgets : vingt-quatre valeurs 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. """ # ── 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. _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)", # 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", # 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)", # É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", # 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", }, } # 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", }, } #: Palettes proposées, dans l'ordre où l'interface les présente. PALETTES = {"dark": "Sombre", "light": "Clair"} _PALETTES = {"dark": _DARK, "light": _LIGHT} #: 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: """Feuille de style d'un bloc de résultat, selon son état. Les blocs affichaient chacun leur propre fond rgba, recopié à la main : même état, teintes différentes d'un widget à l'autre. """ color, bg = _STATE.get(state, _STATE["idle"]) return (f"padding: 8px; border-radius: 6px; font-size: {font_size}px;" f" background: {bg}; color: {color};") def result_view_style(state: str = "idle", font_size: int = 12) -> str: """Même chose pour un QTextEdit en lecture seule (rapport défilant).""" color, bg = _STATE.get(state, _STATE["idle"]) return ( f"QTextEdit {{ background: {bg}; color: {color};" f" border: 1px solid {BORDER_SOFT}; border-radius: 6px;" f" padding: 8px; font-size: {font_size}px; }}" f"QScrollBar:vertical {{ background: transparent; width: 8px;" f" border: none; }}" f"QScrollBar::handle:vertical {{ background: {BG_RAISED};" f" border-radius: 4px; }}" "QScrollBar::add-line:vertical," "QScrollBar::sub-line:vertical { height: 0; }" ) def scrollarea_style() -> str: return ( f"QScrollArea {{ border: none; background: {BG}; }}" f"QScrollBar:vertical {{ background: {BG}; width: 8px; border: none; }}" f"QScrollBar::handle:vertical {{ background: {BG_RAISED};" f" border-radius: 4px; }}" "QScrollBar::add-line:vertical," "QScrollBar::sub-line:vertical { height: 0; }" ) 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;") def primary_button_style(solid: str = "", hover: str = "") -> str: """Bouton d'action principale d'une page (lancer un test, par exemple). 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};" f" color: {TEXT};" f" border-radius: 5px; border: none; }}" f"QPushButton:hover {{ background: {ACCENT_HOVER};" f" color: {ON_SOLID}; }}") def hint_style() -> str: """Encart d'introduction posé en tête de chaque onglet. 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: {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")