facett-core 0.1.1

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Theming.** A palette the custom-painted components (graph/depgraph/map)
//! read from the egui context, plus matching egui `Visuals` for the standard
//! widgets (pop/table/pipeline). Call [`set_theme`] once on the context and every
//! facet follows. Ships a [`Theme::default`] look, a [`Theme::sci_fi`]
//! neon-on-near-black look, and a switchable family of striking palettes
//! ([`Theme::nordic_aurora`], [`Theme::cyberpunk_neon`], [`Theme::amber_crt`],
//! [`Theme::deep_space`], [`Theme::hugin_noir`]). Enumerate them with
//! [`Theme::ALL`] / [`Theme::by_name`] to build a picker.

use egui::{Color32, Context, Id, Ui, Visuals};

/// The facett palette. All custom-painted colours come from here.
///
/// `glow` is the colour the [`effects`](crate::effects) module blooms with
/// (layered alpha strokes). It defaults to `accent` for the legacy palettes via
/// the constructors below, but a striking theme may pick a distinct glow.
#[derive(Clone, Copy, Debug)]
pub struct Theme {
    pub name: &'static str,
    pub bg: Color32,
    pub node_fill: Color32,
    pub node_stroke: Color32,
    pub edge: Color32,
    pub text: Color32,
    pub text_dim: Color32,
    pub accent: Color32,
    pub point: Color32,
    pub panel_bg: Color32,
    pub panel_stroke: Color32,
    /// The colour [`effects`](crate::effects) blooms/shimmers with.
    pub glow: Color32,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            name: "default",
            bg: Color32::from_rgb(18, 18, 24),
            node_fill: Color32::from_rgb(30, 30, 40),
            node_stroke: Color32::from_gray(120),
            edge: Color32::from_gray(90),
            text: Color32::from_gray(220),
            text_dim: Color32::from_gray(150),
            accent: Color32::from_rgb(120, 210, 255),
            point: Color32::from_rgb(90, 200, 140),
            panel_bg: Color32::from_rgba_unmultiplied(16, 26, 42, 236),
            panel_stroke: Color32::from_rgb(80, 130, 180),
            glow: Color32::from_rgb(120, 210, 255),
        }
    }
}

impl Theme {
    /// Every named theme, in picker order. Drives a UI/CLI palette switcher.
    pub const ALL: &'static [fn() -> Theme] = &[
        Theme::default,
        Theme::sci_fi,
        Theme::nordic_aurora,
        Theme::cyberpunk_neon,
        Theme::amber_crt,
        Theme::deep_space,
        Theme::hugin_noir,
    ];

    /// The names of every theme in [`Theme::ALL`] — handy for a CLI `--theme`
    /// flag or a dropdown without constructing each palette.
    pub fn names() -> Vec<&'static str> {
        Self::ALL.iter().map(|ctor| ctor().name).collect()
    }

    /// Look a theme up by its `name` (case-insensitive; `-`/`_`/spaces are
    /// interchangeable, so `"nordic aurora"`, `"nordic-aurora"`, `"Nordic_Aurora"`
    /// all match). `None` if unknown.
    pub fn by_name(name: &str) -> Option<Theme> {
        let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
        let want = norm(name);
        Self::ALL.iter().map(|ctor| ctor()).find(|t| norm(t.name) == want)
    }

    /// Neon-on-near-black — a sci-fi HUD look (cyan/magenta on deep blue-black).
    pub fn sci_fi() -> Self {
        Self {
            name: "sci-fi",
            bg: Color32::from_rgb(6, 10, 18),
            node_fill: Color32::from_rgb(14, 22, 38),
            node_stroke: Color32::from_rgb(0, 200, 220),
            edge: Color32::from_rgb(60, 110, 170),
            text: Color32::from_rgb(180, 235, 255),
            text_dim: Color32::from_rgb(90, 130, 175),
            accent: Color32::from_rgb(0, 255, 225),
            point: Color32::from_rgb(80, 255, 170),
            panel_bg: Color32::from_rgba_unmultiplied(8, 16, 28, 240),
            panel_stroke: Color32::from_rgb(0, 200, 220),
            glow: Color32::from_rgb(0, 255, 225),
        }
    }

    /// **Nordic aurora** — deep fjord-night blue with green/teal aurora ribbons
    /// and a cold violet accent. Calm but luminous; the glow is aurora-green.
    pub fn nordic_aurora() -> Self {
        Self {
            name: "nordic-aurora",
            bg: Color32::from_rgb(10, 18, 28),
            node_fill: Color32::from_rgb(18, 32, 44),
            node_stroke: Color32::from_rgb(80, 220, 180),
            edge: Color32::from_rgb(50, 110, 120),
            text: Color32::from_rgb(214, 240, 234),
            text_dim: Color32::from_rgb(120, 165, 165),
            accent: Color32::from_rgb(120, 230, 200),
            point: Color32::from_rgb(150, 130, 240),
            panel_bg: Color32::from_rgba_unmultiplied(12, 24, 34, 238),
            panel_stroke: Color32::from_rgb(70, 180, 160),
            glow: Color32::from_rgb(90, 255, 190),
        }
    }

    /// **Cyberpunk neon** — hot magenta + electric cyan on bruised purple-black.
    /// Maximum night-city pop; the glow is magenta.
    pub fn cyberpunk_neon() -> Self {
        Self {
            name: "cyberpunk-neon",
            bg: Color32::from_rgb(14, 8, 22),
            node_fill: Color32::from_rgb(26, 14, 38),
            node_stroke: Color32::from_rgb(0, 240, 255),
            edge: Color32::from_rgb(120, 40, 140),
            text: Color32::from_rgb(245, 220, 255),
            text_dim: Color32::from_rgb(160, 110, 180),
            accent: Color32::from_rgb(255, 50, 200),
            point: Color32::from_rgb(0, 240, 255),
            panel_bg: Color32::from_rgba_unmultiplied(20, 10, 30, 240),
            panel_stroke: Color32::from_rgb(255, 50, 200),
            glow: Color32::from_rgb(255, 60, 210),
        }
    }

    /// **Amber CRT** — a warm phosphor terminal: amber-on-black with a dim
    /// scan-line brown. Cosy retro; the glow is amber.
    pub fn amber_crt() -> Self {
        Self {
            name: "amber-crt",
            bg: Color32::from_rgb(14, 10, 4),
            node_fill: Color32::from_rgb(28, 20, 8),
            node_stroke: Color32::from_rgb(255, 176, 64),
            edge: Color32::from_rgb(120, 80, 24),
            text: Color32::from_rgb(255, 200, 110),
            text_dim: Color32::from_rgb(170, 120, 50),
            accent: Color32::from_rgb(255, 176, 64),
            point: Color32::from_rgb(255, 214, 130),
            panel_bg: Color32::from_rgba_unmultiplied(20, 14, 6, 240),
            panel_stroke: Color32::from_rgb(200, 130, 40),
            glow: Color32::from_rgb(255, 190, 90),
        }
    }

    /// **Deep space** — near-black indigo void, starlight-white text, and a
    /// nebula magenta/blue accent. Vast and quiet; the glow is nebula-violet.
    pub fn deep_space() -> Self {
        Self {
            name: "deep-space",
            bg: Color32::from_rgb(6, 7, 16),
            node_fill: Color32::from_rgb(16, 18, 34),
            node_stroke: Color32::from_rgb(130, 150, 255),
            edge: Color32::from_rgb(54, 60, 110),
            text: Color32::from_rgb(226, 230, 248),
            text_dim: Color32::from_rgb(128, 138, 180),
            accent: Color32::from_rgb(150, 130, 255),
            point: Color32::from_rgb(110, 200, 255),
            panel_bg: Color32::from_rgba_unmultiplied(10, 12, 26, 240),
            panel_stroke: Color32::from_rgb(90, 100, 190),
            glow: Color32::from_rgb(170, 140, 255),
        }
    }

    /// **Hugin noir** — the raven's own palette: raven-black ground, bone-white
    /// text, a single blood-red accent. Stark and editorial; the glow is blood.
    pub fn hugin_noir() -> Self {
        Self {
            name: "hugin-noir",
            bg: Color32::from_rgb(10, 10, 11),
            node_fill: Color32::from_rgb(22, 22, 24),
            node_stroke: Color32::from_rgb(232, 226, 214), // bone white
            edge: Color32::from_rgb(70, 70, 74),
            text: Color32::from_rgb(236, 230, 218), // bone white
            text_dim: Color32::from_rgb(140, 138, 134),
            accent: Color32::from_rgb(196, 30, 38), // blood
            point: Color32::from_rgb(196, 30, 38),
            panel_bg: Color32::from_rgba_unmultiplied(16, 16, 17, 242),
            panel_stroke: Color32::from_rgb(150, 24, 30),
            glow: Color32::from_rgb(220, 40, 48),
        }
    }

    /// egui `Visuals` matching the palette — themes the standard widgets
    /// (buttons, progress bars, grids) to fit.
    pub fn visuals(&self) -> Visuals {
        let mut v = Visuals::dark();
        v.override_text_color = Some(self.text);
        v.hyperlink_color = self.accent;
        v.panel_fill = self.bg;
        v.window_fill = self.panel_bg;
        v.extreme_bg_color = self.bg;
        v.faint_bg_color = self.node_fill;
        v.selection.bg_fill = self.accent.linear_multiply(0.35);
        v.selection.stroke.color = self.accent;
        v.widgets.noninteractive.bg_fill = self.node_fill;
        v.widgets.inactive.bg_fill = self.node_fill;
        v.widgets.hovered.bg_stroke.color = self.accent;
        v.widgets.active.bg_stroke.color = self.accent;
        v
    }
}

const THEME_ID: &str = "facett_theme";

/// Store `theme` on the context **and** apply its egui `Visuals`. Call once per
/// frame (cheap) or whenever the theme changes; every facet picks it up.
pub fn set_theme(ctx: &Context, theme: Theme) {
    ctx.set_visuals(theme.visuals());
    ctx.data_mut(|d| d.insert_temp(Id::new(THEME_ID), theme));
}

/// The theme stored on the ui's context (or [`Theme::default`] if none).
///
/// **Readable-data side effect (LAW 6):** every call records the palette it hands
/// out into the [`probe`] thread-local, so a headless test can read back *which
/// palette a component actually consumed* during its paint — proof the component
/// read the active theme rather than a private default. `theme(ui)` is the single
/// universal consumption point for every custom-painted facet, so instrumenting it
/// once gives uniform, per-component palette-consumption evidence.
pub fn theme(ui: &Ui) -> Theme {
    let t = ui.data(|d| d.get_temp::<Theme>(Id::new(THEME_ID))).unwrap_or_default();
    probe::record(t.name);
    t
}

/// **Palette-consumption probe** — the machine-readable witness that a component
/// consumed the active [`Theme`]. [`theme`] records the palette name it returns
/// here; a headless test [`reset`](probe::reset)s the probe, renders one component,
/// then reads [`painted`](probe::painted) to assert the component painted with the
/// palette it was given (not `default`). Thread-local so parallel test cells don't
/// interfere; cheap (a `Cell`), and a no-op cost for production renders that never
/// read it.
pub mod probe {
    use std::cell::Cell;

    thread_local! {
        static PAINTED: Cell<Option<&'static str>> = const { Cell::new(None) };
    }

    /// Record the palette a [`theme`](super::theme) call handed out. Called from
    /// inside `theme(ui)`; the *last* palette read this paint wins (a facet may read
    /// the theme several times — they're all the active palette).
    pub fn record(name: &'static str) {
        PAINTED.with(|p| p.set(Some(name)));
    }

    /// Clear the probe before rendering a component, so [`painted`] reflects only
    /// that component's paint.
    pub fn reset() {
        PAINTED.with(|p| p.set(None));
    }

    /// The palette name the most recent [`theme`](super::theme) call handed out on
    /// this thread, or `None` if nothing has read the theme since [`reset`]. A
    /// component that paints with the active palette returns `Some(palette_name)`.
    pub fn painted() -> Option<&'static str> {
        PAINTED.with(|p| p.get())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn set_and_read_theme_round_trips() {
        let ctx = Context::default();
        set_theme(&ctx, Theme::sci_fi());
        // read inside a ui pass
        let mut got = "";
        let _ = ctx.run(egui::RawInput::default(), |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                got = theme(ui).name;
            });
        });
        assert_eq!(got, "sci-fi");
    }

    #[test]
    fn all_themes_have_unique_names_and_are_lookupable() {
        let names = Theme::names();
        assert_eq!(names.len(), Theme::ALL.len());
        // names are unique
        let mut sorted = names.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.len(), names.len(), "theme names must be unique");
        // the legacy looks are still present
        assert!(names.contains(&"default"));
        assert!(names.contains(&"sci-fi"));
        // every theme round-trips through by_name, including fuzzy forms
        for n in &names {
            assert_eq!(Theme::by_name(n).map(|t| t.name), Some(*n));
        }
        assert_eq!(Theme::by_name("Nordic Aurora").map(|t| t.name), Some("nordic-aurora"));
        assert_eq!(Theme::by_name("AMBER_CRT").map(|t| t.name), Some("amber-crt"));
        assert!(Theme::by_name("nonesuch").is_none());
    }
}