""" THEMES VORTEX — NOIR (defaut, Gotham / poste de commandement) et BLEU ====================================================================== Le theme BLEU est celui de .streamlit/config.toml et de toutes les couleurs ecrites dans le code. Le theme NOIR n'est PAS une seconde copie du style : c'est une table de correspondance BLEU -> NOIR appliquee automatiquement, au moment du rendu, a : - tout HTML/CSS injecte (st.markdown / st.html / components.html, y compris le grand bloc CSS de streamlit_app.py, les bandeaux, l'overlay, le graphe Ontology...) ; - toutes les figures Plotly (st.plotly_chart). Les composants natifs Streamlit (fond, barre laterale, champs, listes, onglets, boutons, alertes, toasts...) recoivent leurs couleurs du bloc CSS_CHROME, ecrit en couleurs BLEU puis converti de la meme facon : une seule source de style pour les deux themes. Le choix est memorise par navigateur (cookie vortex_theme, 1 an). Limite : l'interieur des tableaux st.dataframe est dessine sur un canvas avec les couleurs de config.toml (fond #0a0e12, quasi noir) ; seul leur cadre suit le theme. """ import json import re import streamlit as st THEMES = ("BLEU", "NOIR") DEFAUT = "NOIR" NOM_COOKIE = "vortex_theme" CLE_SESSION = "vx_theme" # ── Correspondance des couleurs BLEU -> NOIR ──────────────────────────────── # Fonds bleu-nuit -> noirs ; gris bleutes -> gris neutres ; accent bleu -> ambre (selection, # element actif, comme les postes de commandement) ; etats (vert / orange / rouge) desatures. HEX_NOIR = { # fonds "#0a0e12": "#000000", "#0d1117": "#030303", "#0f141a": "#080808", "#161b22": "#0e0e0e", "#1a2a4a": "#161616", "#21262d": "#141414", "#30363d": "#262626", # gris / textes "#2a3a4a": "#2b2b2b", "#3a4a5a": "#3a3a3a", "#4a5a6a": "#4c4c4c", "#5a6a7a": "#6a6a6a", "#6a7a8a": "#777777", "#7a8a9a": "#878787", "#8a9aaa": "#999999", "#8b949e": "#8f8f8f", "#a8b8c8": "#c4c4c4", "#c9d1d9": "#dedede", "#e6edf3": "#f0f0f0", # accent "#58a6ff": "#f2c94c", "#388bfd": "#d9ad2f", "#1e88e5": "#e0b43c", "#79c0ff": "#f6d77a", # etats "#54bd4b": "#6fcf97", "#2ecc71": "#6fcf97", "#3fb950": "#6fcf97", "#f39c12": "#f2994a", "#e67e22": "#e8893a", "#f0b90b": "#f2c94c", "#c0392b": "#d9534f", "#e74c3c": "#e05a4f", "#f85149": "#e05a4f", # grilles et axes Plotly (modeles par defaut) : bleutes -> neutres "#283442": "#1c1c1c", "#506784": "#3c3c3c", "#2a3f5f": "#cfcfcf", "#ebf0f8": "#1c1c1c", "#e5ecf6": "#0a0a0a", } RGB_NOIR = { (10, 14, 18): (0, 0, 0), (13, 17, 23): (3, 3, 3), (15, 20, 26): (8, 8, 8), (22, 27, 34): (14, 14, 14), (80, 100, 120): (90, 90, 90), (100, 120, 140): (110, 110, 110), (60, 80, 100): (70, 70, 70), (50, 65, 80): (55, 55, 55), (168, 184, 200): (196, 196, 196), (48, 54, 61): (38, 38, 38), (88, 166, 255): (242, 201, 76), (30, 136, 229): (224, 180, 60), (243, 156, 18): (242, 153, 74), (192, 57, 43): (217, 83, 79), (231, 76, 60): (224, 90, 79), (84, 189, 75): (111, 207, 151), (46, 204, 113): (111, 207, 151), } _RE_HEX = re.compile(r"#[0-9a-fA-F]{6}\b") _RE_RGB = re.compile(r"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*[\d.]+\s*)?\)", re.IGNORECASE) def actif() -> str: try: valeur = st.session_state.get(CLE_SESSION) except Exception: valeur = None return valeur if valeur in THEMES else DEFAUT def est_noir() -> bool: return actif() == "NOIR" def convertir(texte, theme=None) -> str: """Texte (HTML, CSS, JSON) avec les couleurs du theme. BLEU : inchange.""" if (theme or actif()) != "NOIR" or not isinstance(texte, str) or not texte: return texte def _hex(m): return HEX_NOIR.get(m.group(0).lower(), m.group(0)) def _rgb(m): cle = tuple(int(x) for x in m.groups()[:3]) if cle not in RGB_NOIR: return m.group(0) r, g, b = RGB_NOIR[cle] alpha = m.group(4) return f"rgba({r}, {g}, {b}, {alpha.strip(' ,')})" if alpha else f"rgb({r}, {g}, {b})" return _RE_RGB.sub(_rgb, _RE_HEX.sub(_hex, texte)) def convertir_figure(fig, theme=None): """Figure Plotly aux couleurs du theme (copie ; l'original n'est pas modifie).""" if (theme or actif()) != "NOIR" or fig is None or not hasattr(fig, "to_json"): return fig try: import plotly.io as pio return pio.from_json(convertir(fig.to_json(), "NOIR")) except Exception: return fig # ── Installation : conversion automatique au rendu ────────────────────────── def installer(): """Branche la conversion sur markdown / html / components.html / plotly_chart. Idempotent.""" from streamlit.delta_generator import DeltaGenerator import streamlit.components.v1 as components_v1 if getattr(DeltaGenerator, "_vortex_theme_installe", False): return md, ht, pc = DeltaGenerator.markdown, DeltaGenerator.html, DeltaGenerator.plotly_chart def _markdown(self, body, *args, **kwargs): if kwargs.get("unsafe_allow_html") or (len(args) > 0 and args[0] is True): body = convertir(body) return md(self, body, *args, **kwargs) def _html(self, body, *args, **kwargs): return ht(self, convertir(body) if isinstance(body, str) else body, *args, **kwargs) def _plotly(self, figure_or_data, *args, **kwargs): return pc(self, convertir_figure(figure_or_data), *args, **kwargs) DeltaGenerator.markdown, DeltaGenerator.html, DeltaGenerator.plotly_chart = _markdown, _html, _plotly st.markdown = lambda body, *a, **k: DeltaGenerator.markdown(st._main, body, *a, **k) st.html = lambda body, *a, **k: DeltaGenerator.html(st._main, body, *a, **k) st.plotly_chart = lambda fig, *a, **k: DeltaGenerator.plotly_chart(st._main, fig, *a, **k) composant = components_v1.html def _composant(html_texte, *args, **kwargs): return composant(convertir(html_texte), *args, **kwargs) components_v1.html = _composant DeltaGenerator._vortex_theme_installe = True def initialiser(): """Au debut de chaque execution : theme de la session, sinon celui du cookie du navigateur.""" if st.session_state.get(CLE_SESSION) in THEMES: return try: cookie = st.context.cookies.get(NOM_COOKIE) except Exception: cookie = None st.session_state[CLE_SESSION] = cookie if cookie in THEMES else DEFAUT def selecteur(zone=None): """Interrupteur BLEU / NOIR (barre laterale). Le choix est ecrit dans un cookie (1 an).""" zone = zone or st.sidebar # Cookie ecrit a l'execution SUIVANT le changement : un script rendu juste avant st.rerun() # n'atteint jamais le navigateur. a_poser = st.session_state.pop("_vx_cookie_a_poser", None) if a_poser in THEMES: valeur = json.dumps(f"{NOM_COOKIE}={a_poser}; Path=/; Max-Age=31536000; Secure; SameSite=Lax") import streamlit.components.v1 as components_v1 components_v1.html(f"", height=0) noir = zone.toggle("MODE NOIR · GOTHAM", value=est_noir(), key="vx_theme_toggle", help="Thème noir poste de commandement. Désactivé : thème bleu habituel.") choix = "NOIR" if noir else "BLEU" if choix != st.session_state.get(CLE_SESSION): st.session_state[CLE_SESSION] = choix st.session_state["_vx_cookie_a_poser"] = choix st.rerun() # ── Style des composants natifs (ecrit en couleurs BLEU, converti pour NOIR) ─ CSS_CHROME = """ """ # Uniquement en NOIR : typographie "mission suite" (titres fins, grandes capitales espacees). CSS_NOIR = """ """ def css() -> str: """Feuille de style du theme actif (a injecter une fois par execution, apres le CSS principal).""" return convertir(CSS_CHROME) + (CSS_NOIR if est_noir() else "")