Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/viz/facett_theme.rs

//! **facett palette** — the project-LAW palette family the viz paints with.
//!
//! # The palette itself is CONSUMED from `facett-core` (L5)
//! [`Theme`] here IS [`facett_core::theme::Theme`] — a plain re-export, not a
//! copy. This module used to vendor the whole struct (name · bg · node_fill ·
//! node_stroke · edge · text · text_dim · accent · point · panel_bg ·
//! panel_stroke · glow), all seven palette constructors, `ALL`/`names`/`by_name`
//! and `visuals()` verbatim, because nornir's viz was on `eframe::egui` **0.33**
//! while `facett-core` was on egui **0.34** (the two `Color32`/`Visuals` types do
//! not interoperate, so a direct dependency would have linked two eguis into one
//! binary). Both sides are on **egui 0.34.3** now — `eframe::egui` IS the `egui`
//! crate facett paints with — so the vendoring reason is void and the twin is
//! gone. facett owns the palette; nornir consumes it.
//!
//! # What stays here: the nornir SEMANTICS (L5a)
//! A palette is a primitive a third consumer wants as-is → leaf. What nornir
//! layers on top is *domain*, and stays in the conductor:
//!   * the **semantic status ramp** the 🧪 Test matrix colours cells by (green
//!     pass · red fail/stall · amber skip · dim not-run) and the release/run
//!     verdict vocabulary (`succeeded_dry_run`, `failed_bench`, …);
//!   * the 0–100 **health ramp**;
//!   * the shared **chrome** helpers (`heat` / `zebra` / `hover` / `selection` /
//!     `gridline` / `info`) every pane routes through so a palette switch
//!     re-skins the whole app;
//!   * `$NORNIR_VIZ_THEME` → `--theme` → default resolution (C8 CLI-parity LAW).
//!
//! They ride on the [`ThemeExt`] extension trait, so `theme.status_color(…)` and
//! `Theme::resolve_initial(…)` read exactly as before — a pane only adds
//! `use super::facett_theme::ThemeExt;` to its imports. The verdict colours are
//! the canonical [`GREEN`]/[`RED`]/[`AMBER`] (status reads the same across
//! panes); the chrome colours derive from the active palette so they drift with
//! the theme.
//!
//! [`SpaciousMetrics`] + [`apply_spacious_spacing`] also stay: they are nornir's
//! own one-step-more-generous rhythm over facett's `look::metrics` grid, applied
//! once at the top rather than per component.

use eframe::egui::Color32;

/// The facett palette — **the** `facett_core::theme::Theme`, re-exported. Every
/// custom-painted colour in the viz comes from here, so switching the theme
/// re-skins the whole app. Constructors (`default` · `sci_fi` · `nordic_aurora`
/// · `cyberpunk_neon` · `amber_crt` · `deep_space` · `hugin_noir`), the `ALL`
/// picker order, `names`/`by_name` and `visuals()` all live in facett-core.
pub use facett_core::theme::Theme;

/// nornir's **semantic** layer over the shared facett palette (L5a): the
/// test-status / release-verdict / health ramps and the chrome helpers, plus the
/// C8 CLI-parity palette resolution. These are nornir domain vocabulary — a
/// third consumer of the palette would not want `failed_bench` or
/// `$NORNIR_VIZ_THEME` — so they extend the leaf rather than fork it.
///
/// Bring it into scope (`use super::facett_theme::ThemeExt;`) to call
/// `theme.status_color(..)`, `theme.health_color(..)`, `theme.zebra(..)` … and
/// the associated `Theme::resolve_initial(env, flag)`.
pub trait ThemeExt {
    /// Resolve the **initial palette** for a headless / CLI launch (C8 CLI-parity
    /// LAW: every GUI control is also selectable without the GUI). The precedence
    /// is `$NORNIR_VIZ_THEME` (the env selector, like `NORNIR_WORKSPACE`) →
    /// the `--theme` flag value (`flag`) → [`Theme::default`]. An unknown name at
    /// either source is ignored (falls through to the next source / the default),
    /// never an error. `env` is the resolved env value (the binary reads the real
    /// var; tests pass it directly so the resolution is unit-testable without
    /// touching the process environment).
    fn resolve_initial(env: Option<&str>, flag: Option<&str>) -> Theme;

    /// 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.
    fn status_fill(&self, status: &str) -> Color32;

    /// A 0–100 health score → colour ramp (red → amber → green). Honours the
    /// palette's mood by blending the green end toward the palette `point`.
    fn health_color(&self, score: f64) -> Color32;

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

    /// 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.
    fn status_color(&self, status: &str) -> Color32;

    /// 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`.
    fn heat(&self, t: f32) -> Color32;

    /// Zebra-stripe fill for alternating rows (a faint palette-tinted band).
    fn zebra(&self, odd: bool) -> Color32;

    /// Hover-row tint (a whisper of the accent over the row).
    fn hover(&self) -> Color32;

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

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

impl ThemeExt for Theme {
    fn resolve_initial(env: Option<&str>, flag: Option<&str>) -> Theme {
        env.filter(|s| !s.is_empty())
            .and_then(Theme::by_name)
            .or_else(|| flag.filter(|s| !s.is_empty()).and_then(Theme::by_name))
            .unwrap_or_default()
    }

    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
        }
    }

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

    fn info(&self) -> Color32 {
        self.accent
    }

    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
        }
    }

    fn heat(&self, t: f32) -> Color32 {
        lerp(self.edge, self.accent, t.clamp(0.0, 1.0))
    }

    fn zebra(&self, odd: bool) -> Color32 {
        if odd {
            self.node_fill.linear_multiply(0.5)
        } else {
            self.bg.linear_multiply(1.0)
        }
    }

    fn hover(&self) -> Color32 {
        self.accent.linear_multiply(0.07)
    }

    fn selection(&self) -> Color32 {
        self.accent
    }

    fn gridline(&self) -> Color32 {
        self.edge
    }
}

/// Two palettes are the same palette iff they are the same *named* palette —
/// `facett_core::theme::Theme` carries no `PartialEq`, and its `name` is unique
/// across [`Theme::ALL`] (asserted by both facett's and nornir's suites), so the
/// name IS the identity. Used by `app::set_palette` to stay idempotent.
pub fn same_palette(a: &Theme, b: &Theme) -> bool {
    a.name == b.name
}

/// 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 + heat ramps).
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()))
}

/// The **spacious** layout metrics — the single source for every gap / padding /
/// margin the spacious look-and-feel widens. Values track facett's
/// `look::metrics` 4-pt grid (8-pt major rhythm), one step more generous than
/// facett's `windows()` preset (item spacing `8→12 / 4→8`, window margin `8→16`,
/// indent `16→24`, control height `24→28`) so the dense viz panes breathe
/// without drifting off the shared rhythm. This is a nornir *choice of values*
/// on facett's grid, not a copy of facett's preset — the shared palette itself is
/// consumed from `facett_core::theme` (see this module's header).
pub struct SpaciousMetrics;

impl SpaciousMetrics {
    /// Gap between successive items, `[x, y]` (points). facett windows = `[8, 4]`.
    pub const ITEM_SPACING: [f32; 2] = [12.0, 8.0];
    /// Inner button padding, `[x, y]` (points). facett windows = `[12, 6]`.
    pub const BUTTON_PADDING: [f32; 2] = [14.0, 8.0];
    /// Uniform window/panel inner margin (points). facett windows = `8`.
    pub const WINDOW_MARGIN: i8 = 16;
    /// Uniform menu inner margin (points). facett windows = `8`.
    pub const MENU_MARGIN: i8 = 12;
    /// Gap between menu entries (points).
    pub const MENU_SPACING: f32 = 6.0;
    /// Indent step for trees / collapsibles (points). facett windows = `16`.
    pub const INDENT: f32 = 24.0;
    /// Minimum interactive widget size `[w, h]`; height bumped `24→28`.
    pub const INTERACT_SIZE: [f32; 2] = [44.0, 28.0];
    /// Slider track width (points). facett windows = `100`.
    pub const SLIDER_WIDTH: f32 = 130.0;
    /// Combo-box width (points).
    pub const COMBO_WIDTH: f32 = 130.0;
    /// Checkbox / radio icon width (points). facett windows = `16`.
    pub const ICON_WIDTH: f32 = 18.0;
    /// Gap between an icon and its label (points).
    pub const ICON_SPACING: f32 = 6.0;
}

/// Apply the **spacious** look-and-feel in one central pass: widen egui's global
/// [`Spacing`](eframe::egui::style::Spacing) so every standard widget (panels,
/// buttons, grids, trees, menus, sliders) inherits the roomier rhythm with **no
/// per-component change** — the same "set it once at the top" discipline the
/// palette uses via [`Theme::visuals`]. Call it whenever the visuals are
/// (re)installed; it only mutates spacing, leaving the active palette's
/// `Visuals` untouched. Panes that set a *local* `Grid::spacing(..)` still win
/// for their own grid (an honest seam — those tight data grids are intentional).
pub fn apply_spacious_spacing(ctx: &eframe::egui::Context) {
    use eframe::egui::{Margin, vec2};
    // egui 0.35 made styles theme-aware: `style()`/`set_style()` are gone, and
    // `all_styles_mut` applies the change to BOTH the light and dark styles — which is
    // what spacing should do anyway (it is metrics, not colour).
    ctx.all_styles_mut(|style| {
        let s = &mut style.spacing;
        s.item_spacing = vec2(
            SpaciousMetrics::ITEM_SPACING[0],
            SpaciousMetrics::ITEM_SPACING[1],
        );
        s.button_padding = vec2(
            SpaciousMetrics::BUTTON_PADDING[0],
            SpaciousMetrics::BUTTON_PADDING[1],
        );
        s.window_margin = Margin::same(SpaciousMetrics::WINDOW_MARGIN);
        s.menu_margin = Margin::same(SpaciousMetrics::MENU_MARGIN);
        s.menu_spacing = SpaciousMetrics::MENU_SPACING;
        s.indent = SpaciousMetrics::INDENT;
        s.interact_size = vec2(
            SpaciousMetrics::INTERACT_SIZE[0],
            SpaciousMetrics::INTERACT_SIZE[1],
        );
        s.slider_width = SpaciousMetrics::SLIDER_WIDTH;
        s.combo_width = SpaciousMetrics::COMBO_WIDTH;
        s.icon_width = SpaciousMetrics::ICON_WIDTH;
        s.icon_spacing = SpaciousMetrics::ICON_SPACING;
    });
}

#[cfg(test)]
mod tests {
    //! The palette's OWN behaviour (unique names, `by_name` fuzzy lookup, the
    //! `visuals()` widget skin) is tested in `facett-core` — the leaf that owns
    //! it. What nornir tests here is the **seam** (this module really re-exports
    //! facett's palette, over `eframe::egui`'s `Color32`) and the nornir-domain
    //! [`ThemeExt`] ramps + spacing that facett does not carry.

    use super::*;

    /// The seam: `viz::facett_theme::Theme` IS `facett_core::theme::Theme`, and
    /// the whole named family is reachable through it. RED the moment someone
    /// re-vendors a local palette struct.
    #[test]
    fn theme_is_facett_cores_palette_not_a_local_copy() {
        let t: Theme = Theme::hugin_noir();
        let facett_side: facett_core::theme::Theme = t; // same type, no conversion
        assert_eq!(facett_side.name, "hugin-noir");
        // egui types unify: facett's Color32 is `eframe::egui::Color32`.
        let _: Color32 = facett_side.accent;
        // the picker family nornir's palette menu iterates comes from the leaf.
        assert!(
            Theme::ALL.len() >= 7,
            "the whole facett palette family is reachable"
        );
        assert_eq!(
            Theme::by_name("Nordic Aurora").map(|t| t.name),
            Some("nordic-aurora")
        );
        // `visuals()` is facett's now — assert it still themes the widgets.
        let v = Theme::sci_fi().visuals();
        assert_eq!(v.override_text_color, Some(Theme::sci_fi().text));
    }

    #[test]
    fn same_palette_compares_by_unique_name() {
        assert!(same_palette(&Theme::default(), &Theme::default()));
        assert!(!same_palette(&Theme::default(), &Theme::sci_fi()));
        // the identity claim `same_palette` rests on: names are unique.
        let names = Theme::names();
        let mut s = names.clone();
        s.sort_unstable();
        s.dedup();
        assert_eq!(
            s.len(),
            names.len(),
            "theme names unique — the name IS the identity"
        );
    }

    #[test]
    fn resolve_initial_honours_env_then_flag_then_default() {
        // env wins over flag.
        assert_eq!(
            Theme::resolve_initial(Some("cyberpunk-neon"), Some("amber-crt")).name,
            "cyberpunk-neon",
        );
        // flag used when env is absent/empty (CLI parity for the --theme flag).
        assert_eq!(
            Theme::resolve_initial(None, Some("amber-crt")).name,
            "amber-crt"
        );
        assert_eq!(
            Theme::resolve_initial(Some(""), Some("amber-crt")).name,
            "amber-crt"
        );
        // normalisation flows through (case-insensitive, `_`/` ` interchange).
        assert_eq!(
            Theme::resolve_initial(Some("Hugin Noir"), None).name,
            "hugin-noir"
        );
        // nothing chosen → default.
        assert_eq!(Theme::resolve_initial(None, None).name, "default");
        // unknown env falls through to the (valid) flag, not the default.
        assert_eq!(
            Theme::resolve_initial(Some("bogus"), Some("sci-fi")).name,
            "sci-fi"
        );
        // unknown at both sources → default (never an error / panic).
        assert_eq!(
            Theme::resolve_initial(Some("bogus"), Some("also-bogus")).name,
            "default"
        );
    }

    #[test]
    fn spacious_metrics_are_generous_and_on_the_rhythm_grid() {
        use SpaciousMetrics as M;
        // Spacious is strictly more generous than facett's `windows()` rhythm.
        assert!(
            M::ITEM_SPACING[0] > 8.0 && M::ITEM_SPACING[1] > 4.0,
            "item spacing widened"
        );
        assert!(f32::from(M::WINDOW_MARGIN) > 8.0, "window margin widened");
        assert!(M::INDENT > 16.0, "indent widened");
        assert!(M::INTERACT_SIZE[1] >= 28.0, "control height >= 28");
        // The layout-rhythm values sit on the 4-pt grid (facett look-and-feel §9);
        // button_padding is sub-rhythm (2-pt steps allowed) so it is excluded.
        let on4 = |x: f32| (x % 4.0).abs() < 1e-3;
        assert!(
            on4(M::ITEM_SPACING[0]) && on4(M::ITEM_SPACING[1]),
            "item spacing on 4-pt grid"
        );
        assert!(
            on4(f32::from(M::WINDOW_MARGIN)),
            "window margin on 4-pt grid"
        );
        assert!(on4(M::INDENT), "indent on 4-pt grid");
    }

    #[test]
    fn apply_spacious_spacing_installs_the_widened_spacing() {
        let ctx = eframe::egui::Context::default();
        apply_spacious_spacing(&ctx);
        let s = &ctx.style_of(ctx.theme()).spacing;
        assert_eq!(s.item_spacing.x, SpaciousMetrics::ITEM_SPACING[0]);
        assert_eq!(s.item_spacing.y, SpaciousMetrics::ITEM_SPACING[1]);
        assert_eq!(s.indent, SpaciousMetrics::INDENT);
        assert_eq!(s.interact_size.y, SpaciousMetrics::INTERACT_SIZE[1]);
        assert_eq!(
            i8::from(s.window_margin.top),
            SpaciousMetrics::WINDOW_MARGIN
        );
    }

    #[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);
    }
}