nornir 0.4.22

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! **facett palette** (ported) — the project-LAW palette family the viz paints
//! with, vendored here because nornir's viz is on `egui 0.33` while the upstream
//! `facett-core` crate is on `egui 0.34` (the two egui `Color32`/`Ui` types do
//! not interoperate, so a direct dependency would mean two egui versions linked
//! into one binary). The colours / names / `ALL` order are a **verbatim copy** of
//! `facett_core::theme::Theme` so the Test pane is palette-switchable across the
//! exact same facett palettes (default · sci-fi · nordic-aurora · cyberpunk-neon
//! · amber-crt · deep-space · hugin-noir) and looks identical to a real facett
//! surface. When facett ships on egui 0.33 this module collapses to a re-export.
//!
//! Beyond the base palette this adds the **semantic status ramp** the Test
//! matrix colours cells by (green pass · red fail/stall · amber skip · dim
//! not-run) and a 0–100 **health ramp**, both derived from the active palette's
//! accent so they shift with the theme.

use eframe::egui::{Color32, Visuals};

/// The facett palette. Every custom-painted colour in the Test matrix comes from
/// here, so switching the theme re-skins the whole pane.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
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 bloom/shimmer 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 the 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`].
    pub fn names() -> Vec<&'static str> {
        Self::ALL.iter().map(|ctor| ctor().name).collect()
    }

    /// Look a theme up by `name` (case-insensitive; `-`/`_`/spaces interchange).
    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.
    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.
    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.
    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.
    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** — raven-black ground, bone-white text, blood-red accent.
    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),
            edge: Color32::from_rgb(70, 70, 74),
            text: Color32::from_rgb(236, 230, 218),
            text_dim: Color32::from_rgb(140, 138, 134),
            accent: Color32::from_rgb(196, 30, 38),
            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 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
    }

    // ── semantic status + health ramps (derived from the active palette) ─────

    /// The cell colour for a test status, on this palette. `pass` is green,
    /// `fail`/`stalled` red, `skip`/`ignored` amber-grey, anything else (incl.
    /// "not run") a faint node tint.
    pub fn status_fill(&self, status: &str) -> Color32 {
        match status {
            "pass" => GREEN,
            "fail" => RED,
            "stalled" => Color32::from_rgb(230, 150, 60),
            "skip" => Color32::from_rgb(150, 150, 160),
            "ignored" => Color32::from_rgb(120, 120, 130),
            _ => self.node_fill, // not-run: faint, palette-tinted
        }
    }

    /// A 0–100 health score → colour ramp (red → amber → green). Honours the
    /// palette's mood by blending the green end toward the palette `point`.
    pub fn health_color(&self, score: f64) -> Color32 {
        let t = (score / 100.0).clamp(0.0, 1.0) as f32;
        if t < 0.5 {
            // red → amber over [0, 0.5]
            lerp(RED, Color32::from_rgb(235, 185, 70), t / 0.5)
        } else {
            // amber → green over [0.5, 1.0]
            lerp(Color32::from_rgb(235, 185, 70), GREEN, (t - 0.5) / 0.5)
        }
    }

    // ── the shared SEMANTIC ramp (C8) ────────────────────────────────────────
    // Every pane routes its status / accent / chrome colours through these so a
    // palette switch re-skins the *whole* app, not just the Test matrix. The
    // verdict colours are the canonical GREEN/RED/AMBER (status reads the same
    // across panes); the chrome colours (`grid`, `zebra`, `hover`, `selection`,
    // …) are derived from the active palette so they drift with the theme.

    /// Canonical "info / neutral-blue" highlight (binary-start, info notes) —
    /// the palette accent, so info text shifts with the theme.
    pub fn info(&self) -> Color32 { self.accent }

    /// A release/run/op status → colour, palette-aware on the neutral end.
    /// `succeeded*`/`ok`/`pass`/`done` → green, `failed*`/`fail`/`error` → red,
    /// `running`/`warn`/`dry_run` → amber, anything else → the palette's dim text.
    pub fn status_color(&self, status: &str) -> Color32 {
        let s = status.to_ascii_lowercase();
        if s.starts_with("succeeded_dry_run") || s == "dry_run" || s == "warn" {
            AMBER
        } else if s.starts_with("succeeded") || s == "ok" || s == "pass" || s == "passed" || s == "done" || s == "green" {
            GREEN
        } else if s.starts_with("failed_bench") {
            Color32::from_rgb(222, 130, 60) // bench-fail: amber-red
        } else if s.starts_with("fail") || s == "error" || s == "red" || s == "stalled" {
            RED
        } else if s == "running" || s == "in_progress" || s == "inprogress" {
            AMBER
        } else {
            self.text_dim
        }
    }

    /// The colour for a 0..N "heat" / degree ramp (node fan-out, knowledge
    /// bubbles): the palette `edge` (cool) → `accent` (hot) blended by `t∈0..1`.
    pub fn heat(&self, t: f32) -> Color32 {
        lerp(self.edge, self.accent, t.clamp(0.0, 1.0))
    }

    /// Zebra-stripe fill for alternating rows (a faint palette-tinted band).
    pub fn zebra(&self, odd: bool) -> Color32 {
        if odd {
            self.node_fill.linear_multiply(0.5)
        } else {
            self.bg.linear_multiply(1.0)
        }
    }

    /// Hover-row tint (a whisper of the accent over the row).
    pub fn hover(&self) -> Color32 {
        self.accent.linear_multiply(0.07)
    }

    /// Selection accent (used for the selected row/cell/node outline).
    pub fn selection(&self) -> Color32 {
        self.accent
    }

    /// Gridline / separator colour on this palette.
    pub fn gridline(&self) -> Color32 {
        self.edge
    }
}

/// Canonical amber used for in-progress / warning / dry-run across panes.
pub const AMBER: Color32 = Color32::from_rgb(228, 170, 70);

/// Canonical pass-green used across the status + health ramps.
pub const GREEN: Color32 = Color32::from_rgb(80, 190, 120);
/// Canonical fail-red.
pub const RED: Color32 = Color32::from_rgb(222, 78, 78);

/// Linear blend of two colours (used by the health ramp).
fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
    let t = t.clamp(0.0, 1.0);
    let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
    Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b()))
}

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

    #[test]
    fn all_themes_unique_and_lookupable() {
        let names = Theme::names();
        assert_eq!(names.len(), Theme::ALL.len());
        let mut s = names.clone();
        s.sort_unstable();
        s.dedup();
        assert_eq!(s.len(), names.len(), "theme names unique");
        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!(Theme::by_name("nonesuch").is_none());
    }

    #[test]
    fn status_fill_maps_statuses() {
        let t = Theme::default();
        assert_eq!(t.status_fill("pass"), GREEN);
        assert_eq!(t.status_fill("fail"), RED);
        assert_ne!(t.status_fill("skip"), GREEN);
        // not-run is the faint palette tint, distinct from any verdict colour.
        assert_eq!(t.status_fill(""), t.node_fill);
    }

    #[test]
    fn status_color_maps_release_verdicts() {
        let t = Theme::default();
        assert_eq!(t.status_color("succeeded"), GREEN);
        assert_eq!(t.status_color("ok"), GREEN);
        assert_eq!(t.status_color("pass"), GREEN);
        assert_eq!(t.status_color("succeeded_dry_run"), AMBER);
        assert_eq!(t.status_color("running"), AMBER);
        assert_eq!(t.status_color("failed_test"), RED);
        assert_eq!(t.status_color("stalled"), RED);
        // bench-fail is its own amber-red, distinct from a plain fail.
        assert_ne!(t.status_color("failed_bench"), RED);
        // an unknown verdict is the palette's dim text — so it shifts per theme.
        assert_eq!(t.status_color("whatever"), t.text_dim);
        assert_eq!(Theme::sci_fi().status_color("???"), Theme::sci_fi().text_dim);
    }

    #[test]
    fn chrome_helpers_follow_the_palette() {
        // gridline/hover/selection/heat must DIFFER across palettes (so a switch
        // re-skins the chrome, not just the verdicts).
        let a = Theme::default();
        let b = Theme::cyberpunk_neon();
        assert_ne!(a.gridline(), b.gridline(), "gridline shifts per palette");
        assert_ne!(a.selection(), b.selection(), "selection shifts per palette");
        assert_ne!(a.heat(1.0), b.heat(1.0), "heat-hot end shifts per palette");
        // heat ramps edge→accent.
        assert_eq!(a.heat(0.0), a.edge);
        assert_eq!(a.heat(1.0), a.accent);
    }

    #[test]
    fn health_ramp_runs_red_to_green() {
        let t = Theme::default();
        assert_eq!(t.health_color(0.0), RED);
        assert_eq!(t.health_color(100.0), GREEN);
        // midpoint is the amber knee, neither pure red nor green.
        let mid = t.health_color(50.0);
        assert_ne!(mid, RED);
        assert_ne!(mid, GREEN);
    }
}