"""
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 "")