Skip to main content

facett_core/
theme.rs

1//! **Theming.** A palette the custom-painted components (graph/depgraph/map)
2//! read from the egui context, plus matching egui `Visuals` for the standard
3//! widgets (pop/table/pipeline). Call [`set_theme`] once on the context and every
4//! facet follows. Ships a [`Theme::default`] look, a [`Theme::sci_fi`]
5//! neon-on-near-black look, and a switchable family of striking palettes
6//! ([`Theme::nordic_aurora`], [`Theme::cyberpunk_neon`], [`Theme::amber_crt`],
7//! [`Theme::deep_space`], [`Theme::hugin_noir`]). Enumerate them with
8//! [`Theme::ALL`] / [`Theme::by_name`] to build a picker.
9
10use egui::{Color32, Context, Id, Ui, Visuals};
11
12/// The facett palette. All custom-painted colours come from here.
13///
14/// `glow` is the colour the [`effects`](crate::effects) module blooms with
15/// (layered alpha strokes). It defaults to `accent` for the legacy palettes via
16/// the constructors below, but a striking theme may pick a distinct glow.
17#[derive(Clone, Copy, Debug)]
18pub struct Theme {
19    pub name: &'static str,
20    pub bg: Color32,
21    pub node_fill: Color32,
22    pub node_stroke: Color32,
23    pub edge: Color32,
24    pub text: Color32,
25    pub text_dim: Color32,
26    pub accent: Color32,
27    pub point: Color32,
28    pub panel_bg: Color32,
29    pub panel_stroke: Color32,
30    /// The colour [`effects`](crate::effects) blooms/shimmers with.
31    pub glow: Color32,
32}
33
34impl Default for Theme {
35    fn default() -> Self {
36        Self {
37            name: "default",
38            bg: Color32::from_rgb(18, 18, 24),
39            node_fill: Color32::from_rgb(30, 30, 40),
40            node_stroke: Color32::from_gray(120),
41            edge: Color32::from_gray(90),
42            text: Color32::from_gray(220),
43            text_dim: Color32::from_gray(150),
44            accent: Color32::from_rgb(120, 210, 255),
45            point: Color32::from_rgb(90, 200, 140),
46            panel_bg: Color32::from_rgba_unmultiplied(16, 26, 42, 236),
47            panel_stroke: Color32::from_rgb(80, 130, 180),
48            glow: Color32::from_rgb(120, 210, 255),
49        }
50    }
51}
52
53impl Theme {
54    /// Every named theme, in picker order. Drives a UI/CLI palette switcher.
55    pub const ALL: &'static [fn() -> Theme] = &[
56        Theme::default,
57        Theme::sci_fi,
58        Theme::nordic_aurora,
59        Theme::cyberpunk_neon,
60        Theme::amber_crt,
61        Theme::deep_space,
62        Theme::hugin_noir,
63    ];
64
65    /// The names of every theme in [`Theme::ALL`] — handy for a CLI `--theme`
66    /// flag or a dropdown without constructing each palette.
67    pub fn names() -> Vec<&'static str> {
68        Self::ALL.iter().map(|ctor| ctor().name).collect()
69    }
70
71    /// Look a theme up by its `name` (case-insensitive; `-`/`_`/spaces are
72    /// interchangeable, so `"nordic aurora"`, `"nordic-aurora"`, `"Nordic_Aurora"`
73    /// all match). `None` if unknown.
74    pub fn by_name(name: &str) -> Option<Theme> {
75        let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
76        let want = norm(name);
77        Self::ALL.iter().map(|ctor| ctor()).find(|t| norm(t.name) == want)
78    }
79
80    /// Neon-on-near-black — a sci-fi HUD look (cyan/magenta on deep blue-black).
81    pub fn sci_fi() -> Self {
82        Self {
83            name: "sci-fi",
84            bg: Color32::from_rgb(6, 10, 18),
85            node_fill: Color32::from_rgb(14, 22, 38),
86            node_stroke: Color32::from_rgb(0, 200, 220),
87            edge: Color32::from_rgb(60, 110, 170),
88            text: Color32::from_rgb(180, 235, 255),
89            text_dim: Color32::from_rgb(90, 130, 175),
90            accent: Color32::from_rgb(0, 255, 225),
91            point: Color32::from_rgb(80, 255, 170),
92            panel_bg: Color32::from_rgba_unmultiplied(8, 16, 28, 240),
93            panel_stroke: Color32::from_rgb(0, 200, 220),
94            glow: Color32::from_rgb(0, 255, 225),
95        }
96    }
97
98    /// **Nordic aurora** — deep fjord-night blue with green/teal aurora ribbons
99    /// and a cold violet accent. Calm but luminous; the glow is aurora-green.
100    pub fn nordic_aurora() -> Self {
101        Self {
102            name: "nordic-aurora",
103            bg: Color32::from_rgb(10, 18, 28),
104            node_fill: Color32::from_rgb(18, 32, 44),
105            node_stroke: Color32::from_rgb(80, 220, 180),
106            edge: Color32::from_rgb(50, 110, 120),
107            text: Color32::from_rgb(214, 240, 234),
108            text_dim: Color32::from_rgb(120, 165, 165),
109            accent: Color32::from_rgb(120, 230, 200),
110            point: Color32::from_rgb(150, 130, 240),
111            panel_bg: Color32::from_rgba_unmultiplied(12, 24, 34, 238),
112            panel_stroke: Color32::from_rgb(70, 180, 160),
113            glow: Color32::from_rgb(90, 255, 190),
114        }
115    }
116
117    /// **Cyberpunk neon** — hot magenta + electric cyan on bruised purple-black.
118    /// Maximum night-city pop; the glow is magenta.
119    pub fn cyberpunk_neon() -> Self {
120        Self {
121            name: "cyberpunk-neon",
122            bg: Color32::from_rgb(14, 8, 22),
123            node_fill: Color32::from_rgb(26, 14, 38),
124            node_stroke: Color32::from_rgb(0, 240, 255),
125            edge: Color32::from_rgb(120, 40, 140),
126            text: Color32::from_rgb(245, 220, 255),
127            text_dim: Color32::from_rgb(160, 110, 180),
128            accent: Color32::from_rgb(255, 50, 200),
129            point: Color32::from_rgb(0, 240, 255),
130            panel_bg: Color32::from_rgba_unmultiplied(20, 10, 30, 240),
131            panel_stroke: Color32::from_rgb(255, 50, 200),
132            glow: Color32::from_rgb(255, 60, 210),
133        }
134    }
135
136    /// **Amber CRT** — a warm phosphor terminal: amber-on-black with a dim
137    /// scan-line brown. Cosy retro; the glow is amber.
138    pub fn amber_crt() -> Self {
139        Self {
140            name: "amber-crt",
141            bg: Color32::from_rgb(14, 10, 4),
142            node_fill: Color32::from_rgb(28, 20, 8),
143            node_stroke: Color32::from_rgb(255, 176, 64),
144            edge: Color32::from_rgb(120, 80, 24),
145            text: Color32::from_rgb(255, 200, 110),
146            text_dim: Color32::from_rgb(170, 120, 50),
147            accent: Color32::from_rgb(255, 176, 64),
148            point: Color32::from_rgb(255, 214, 130),
149            panel_bg: Color32::from_rgba_unmultiplied(20, 14, 6, 240),
150            panel_stroke: Color32::from_rgb(200, 130, 40),
151            glow: Color32::from_rgb(255, 190, 90),
152        }
153    }
154
155    /// **Deep space** — near-black indigo void, starlight-white text, and a
156    /// nebula magenta/blue accent. Vast and quiet; the glow is nebula-violet.
157    pub fn deep_space() -> Self {
158        Self {
159            name: "deep-space",
160            bg: Color32::from_rgb(6, 7, 16),
161            node_fill: Color32::from_rgb(16, 18, 34),
162            node_stroke: Color32::from_rgb(130, 150, 255),
163            edge: Color32::from_rgb(54, 60, 110),
164            text: Color32::from_rgb(226, 230, 248),
165            text_dim: Color32::from_rgb(128, 138, 180),
166            accent: Color32::from_rgb(150, 130, 255),
167            point: Color32::from_rgb(110, 200, 255),
168            panel_bg: Color32::from_rgba_unmultiplied(10, 12, 26, 240),
169            panel_stroke: Color32::from_rgb(90, 100, 190),
170            glow: Color32::from_rgb(170, 140, 255),
171        }
172    }
173
174    /// **Hugin noir** — the raven's own palette: raven-black ground, bone-white
175    /// text, a single blood-red accent. Stark and editorial; the glow is blood.
176    pub fn hugin_noir() -> Self {
177        Self {
178            name: "hugin-noir",
179            bg: Color32::from_rgb(10, 10, 11),
180            node_fill: Color32::from_rgb(22, 22, 24),
181            node_stroke: Color32::from_rgb(232, 226, 214), // bone white
182            edge: Color32::from_rgb(70, 70, 74),
183            text: Color32::from_rgb(236, 230, 218), // bone white
184            text_dim: Color32::from_rgb(140, 138, 134),
185            accent: Color32::from_rgb(196, 30, 38), // blood
186            point: Color32::from_rgb(196, 30, 38),
187            panel_bg: Color32::from_rgba_unmultiplied(16, 16, 17, 242),
188            panel_stroke: Color32::from_rgb(150, 24, 30),
189            glow: Color32::from_rgb(220, 40, 48),
190        }
191    }
192
193    /// egui `Visuals` matching the palette — themes the standard widgets
194    /// (buttons, progress bars, grids) to fit.
195    pub fn visuals(&self) -> Visuals {
196        let mut v = Visuals::dark();
197        v.override_text_color = Some(self.text);
198        v.hyperlink_color = self.accent;
199        v.panel_fill = self.bg;
200        v.window_fill = self.panel_bg;
201        v.extreme_bg_color = self.bg;
202        v.faint_bg_color = self.node_fill;
203        v.selection.bg_fill = self.accent.linear_multiply(0.35);
204        v.selection.stroke.color = self.accent;
205        v.widgets.noninteractive.bg_fill = self.node_fill;
206        v.widgets.inactive.bg_fill = self.node_fill;
207        v.widgets.hovered.bg_stroke.color = self.accent;
208        v.widgets.active.bg_stroke.color = self.accent;
209        v
210    }
211}
212
213const THEME_ID: &str = "facett_theme";
214
215/// Store `theme` on the context **and** apply its egui `Visuals`. Call once per
216/// frame (cheap) or whenever the theme changes; every facet picks it up.
217pub fn set_theme(ctx: &Context, theme: Theme) {
218    ctx.set_visuals(theme.visuals());
219    ctx.data_mut(|d| d.insert_temp(Id::new(THEME_ID), theme));
220}
221
222/// The theme stored on the ui's context (or [`Theme::default`] if none).
223///
224/// **Readable-data side effect (LAW 6):** every call records the palette it hands
225/// out into the [`probe`] thread-local, so a headless test can read back *which
226/// palette a component actually consumed* during its paint — proof the component
227/// read the active theme rather than a private default. `theme(ui)` is the single
228/// universal consumption point for every custom-painted facet, so instrumenting it
229/// once gives uniform, per-component palette-consumption evidence.
230pub fn theme(ui: &Ui) -> Theme {
231    let t = ui.data(|d| d.get_temp::<Theme>(Id::new(THEME_ID))).unwrap_or_default();
232    probe::record(t.name);
233    t
234}
235
236/// **Palette-consumption probe** — the machine-readable witness that a component
237/// consumed the active [`Theme`]. [`theme`] records the palette name it returns
238/// here; a headless test [`reset`](probe::reset)s the probe, renders one component,
239/// then reads [`painted`](probe::painted) to assert the component painted with the
240/// palette it was given (not `default`). Thread-local so parallel test cells don't
241/// interfere; cheap (a `Cell`), and a no-op cost for production renders that never
242/// read it.
243pub mod probe {
244    use std::cell::Cell;
245
246    thread_local! {
247        static PAINTED: Cell<Option<&'static str>> = const { Cell::new(None) };
248    }
249
250    /// Record the palette a [`theme`](super::theme) call handed out. Called from
251    /// inside `theme(ui)`; the *last* palette read this paint wins (a facet may read
252    /// the theme several times — they're all the active palette).
253    pub fn record(name: &'static str) {
254        PAINTED.with(|p| p.set(Some(name)));
255    }
256
257    /// Clear the probe before rendering a component, so [`painted`] reflects only
258    /// that component's paint.
259    pub fn reset() {
260        PAINTED.with(|p| p.set(None));
261    }
262
263    /// The palette name the most recent [`theme`](super::theme) call handed out on
264    /// this thread, or `None` if nothing has read the theme since [`reset`]. A
265    /// component that paints with the active palette returns `Some(palette_name)`.
266    pub fn painted() -> Option<&'static str> {
267        PAINTED.with(|p| p.get())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn set_and_read_theme_round_trips() {
277        let ctx = Context::default();
278        set_theme(&ctx, Theme::sci_fi());
279        // read inside a ui pass
280        let mut got = "";
281        let _ = ctx.run(egui::RawInput::default(), |ctx| {
282            egui::CentralPanel::default().show(ctx, |ui| {
283                got = theme(ui).name;
284            });
285        });
286        assert_eq!(got, "sci-fi");
287    }
288
289    #[test]
290    fn all_themes_have_unique_names_and_are_lookupable() {
291        let names = Theme::names();
292        assert_eq!(names.len(), Theme::ALL.len());
293        // names are unique
294        let mut sorted = names.clone();
295        sorted.sort_unstable();
296        sorted.dedup();
297        assert_eq!(sorted.len(), names.len(), "theme names must be unique");
298        // the legacy looks are still present
299        assert!(names.contains(&"default"));
300        assert!(names.contains(&"sci-fi"));
301        // every theme round-trips through by_name, including fuzzy forms
302        for n in &names {
303            assert_eq!(Theme::by_name(n).map(|t| t.name), Some(*n));
304        }
305        assert_eq!(Theme::by_name("Nordic Aurora").map(|t| t.name), Some("nordic-aurora"));
306        assert_eq!(Theme::by_name("AMBER_CRT").map(|t| t.name), Some("amber-crt"));
307        assert!(Theme::by_name("nonesuch").is_none());
308    }
309}