nornir 0.4.28

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **facett-ui** — the generic, *non-nornir-specific* viz widgets, factored out
//! of the individual panes so they're reused (and themed) from one place (D3).
//!
//! # Why this lives in the viz crate (the egui-version blocker)
//!
//! These widgets — status chips, metric badges, sparklines, zebra rows, the
//! health pill, the dep-graph node style — are **pure facett material**: nothing
//! here knows about releases, repos, or the warehouse. They *should* live in the
//! upstream `facett` crate so korp / Njord / any egui surface can paint the same
//! components. They do **not** yet, for one mechanical reason:
//!
//! > nornir's viz is pinned to **egui 0.33** (the highest `egui-snarl 0.9`, the
//! > Funnel node-graph, supports) while `facett` is on **egui 0.34**. egui's
//! > `Color32` / `Painter` / `Ui` types are version-specific and do **not**
//! > interoperate, so a direct `facett-grid` / `facett-table` dependency would
//! > link *two* egui versions into one binary. This is the same skew the L5
//! > agent hit with [`super::facett_theme`], which is why the palette is
//! > *vendored* here rather than imported.
//!
//! **Migration plan** (see `.nornir/theming-and-facett-migration.md`): when
//! `egui-snarl` ships on egui 0.34 (or the Funnel moves off snarl), the viz
//! bumps to 0.34, this module + `facett_theme` collapse into thin re-exports of
//! `facett::widgets` / `facett::Theme`, and the panes import them directly. Until
//! then every widget here takes a [`Theme`] so it's *already* palette-driven —
//! the move is a re-home, not a rewrite.
//!
//! Everything is palette-aware: pass the active [`Theme`] and the widget paints
//! itself in that palette's ramps, so a palette switch re-skins it for free.

use eframe::egui::{self, Color32, CornerRadius, FontId, Pos2, Rect, RichText, Stroke, StrokeKind, Vec2};

use super::facett_theme::Theme;

/// A status **chip** glyph + colour on the active palette — `(label, colour)`.
/// Generic verdict vocabulary (pass/fail/skip/ignore/stall/running); panes map
/// their domain status onto these words. Colours come from [`Theme`] so the chip
/// re-skins per palette.
pub fn status_chip(theme: &Theme, status: &str) -> (&'static str, Color32) {
    let s = status.to_ascii_lowercase();
    match s.as_str() {
        "pass" | "passed" | "ok" | "succeeded" | "done" => ("PASS", super::facett_theme::GREEN),
        "fail" | "failed" | "error" => ("FAIL", super::facett_theme::RED),
        "ignored" | "ignore" => ("IGN", theme.text_dim),
        "stalled" | "stall" => ("STALL", super::facett_theme::AMBER),
        "skip" | "skipped" => ("SKIP", Color32::from_rgb(150, 150, 160)),
        "running" | "in_progress" => ("RUN", super::facett_theme::AMBER),
        _ => ("?", theme.text_dim),
    }
}

/// A small status **dot** label (●) coloured by the palette status ramp, with
/// a count, e.g. `● 12 green`. Reused by summary strips.
pub fn status_dot(ui: &mut egui::Ui, theme: &Theme, color: Color32, label: &str, n: usize) {
    ui.label(RichText::new("").color(color));
    ui.label(RichText::new(format!("{n} {label}")).color(theme.text));
    ui.add_space(6.0);
}

/// Zebra-stripe a row rect (odd rows get the faint palette band). Returns the
/// rect so callers can chain.
pub fn zebra_row(painter: &egui::Painter, theme: &Theme, rect: Rect, odd: bool) {
    if odd {
        painter.rect_filled(rect, CornerRadius::same(2), theme.zebra(true));
    }
}

/// Hover-highlight a row (a whisper of the accent), if `hovered`.
pub fn hover_row(painter: &egui::Painter, theme: &Theme, rect: Rect, hovered: bool) {
    if hovered {
        painter.rect_filled(rect, CornerRadius::same(2), theme.hover());
    }
}

/// A 0–100 **health pill** on the palette's health ramp (red→amber→green).
pub fn health_pill(painter: &egui::Painter, theme: &Theme, score: f64, top_left: Pos2, w: f32, h: f32) {
    let pill = Rect::from_min_size(Pos2::new(top_left.x + 4.0, top_left.y + 9.0), Vec2::new(w - 12.0, h - 18.0));
    let col = theme.health_color(score);
    painter.rect_filled(pill, CornerRadius::same(7), col.linear_multiply(0.28));
    painter.rect_stroke(pill, CornerRadius::same(7), Stroke::new(1.5, col), StrokeKind::Inside);
    painter.text(
        pill.center(),
        egui::Align2::CENTER_CENTER,
        format!("{score:.0}"),
        FontId::proportional(16.0),
        col,
    );
}

/// A tiny **sparkline** of a value series, themed (line = palette `point`).
/// Values are self-normalized; higher = up. No-ops for <2 points.
pub fn sparkline(painter: &egui::Painter, theme: &Theme, series: &[f64], rect: Rect) {
    if series.len() < 2 {
        return;
    }
    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
    for &v in series {
        lo = lo.min(v);
        hi = hi.max(v);
    }
    let span = (hi - lo).max(1.0);
    let n = series.len();
    let pts: Vec<Pos2> = series
        .iter()
        .enumerate()
        .map(|(i, &v)| {
            let t = i as f32 / (n - 1) as f32;
            let x = rect.min.x + t * rect.width();
            let norm = ((v - lo) / span) as f32;
            let y = rect.max.y - norm * rect.height();
            Pos2::new(x, y)
        })
        .collect();
    for w in pts.windows(2) {
        painter.line_segment([w[0], w[1]], Stroke::new(1.4, theme.point));
    }
    if let Some(last) = pts.last() {
        painter.circle_filled(*last, 1.8, theme.point);
    }
}

/// The shared **dep-graph node** style: a rounded rect filled in the palette
/// node-fill (or a selected tint), stroked by `stroke`, with the label centred.
/// Reused by the Dep Graph / Time-Travel graph / Release lit-graph node painters.
pub fn graph_node(
    painter: &egui::Painter,
    theme: &Theme,
    rect: Rect,
    label: &str,
    stroke: Color32,
    selected: bool,
) {
    let fill = if selected {
        theme.accent.linear_multiply(0.18)
    } else {
        theme.node_fill
    };
    painter.rect_filled(rect, CornerRadius::same(6), fill);
    painter.rect_stroke(
        rect,
        CornerRadius::same(6),
        Stroke::new(if selected { 2.5 } else { 1.5 }, stroke),
        StrokeKind::Inside,
    );
    painter.text(
        rect.center(),
        egui::Align2::CENTER_CENTER,
        label,
        FontId::proportional(13.0),
        theme.text,
    );
}

// ─────────────────────────────────────────────────────────────────────────
//  TabBar / Toolbar — the themed nav top-bar (D3b)
// ─────────────────────────────────────────────────────────────────────────

/// One tab in a [`TabBar`]: a stable `id` (the value emitted in `state_json`
/// and matched on click) plus the human `label` egui paints on the header. The
/// `label` is the full button text (icon + words, e.g. `"🧵 Timeline"`) so an
/// AccessKit/robot click finds it by the same string the user reads.
///
/// `id` is what callers switch on (typically a tab enum's debug name); `label`
/// is purely presentational. Keeping them separate lets the host rename a label
/// without breaking click-by-id wiring or the `state_json` contract.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabItem {
    /// Stable identifier — emitted in `state_json["tabs"][].id` / `["active"]`
    /// and returned from [`TabBar::show`] on click. Robot tests + the control
    /// channel resolve a tab by this id.
    pub id: String,
    /// The full header text egui paints (icon glyph + label, e.g. `"🔗 Dep Graph"`).
    pub label: String,
}

impl TabItem {
    /// Build a tab from an `id` and the full `label` (icon already prefixed).
    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
        Self { id: id.into(), label: label.into() }
    }
}

/// A declarative descriptor of one **picker / toggle** the bar hosts in its
/// left/right slots (workspace combo, palette combo, About toggle, live
/// checkbox, …). The bar does not paint these — the host paints them through a
/// slot closure — but it records this descriptor so `state_json` can report
/// *which* controls are present and their current values per the "components
/// expose readable data" LAW, without the bar knowing anything nornir-specific.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BarControl {
    /// Stable key, e.g. `"workspace"`, `"palette"`, `"about"`, `"live"`.
    pub key: String,
    /// Control kind, e.g. `"picker"`, `"toggle"`, `"button"` — for the dump.
    pub kind: String,
    /// The current value as a string (selected option / `"on"`/`"off"`), for
    /// the `state_json` value field. Empty when valueless.
    pub value: String,
}

impl BarControl {
    /// A dropdown/combo picker reporting its selected value.
    pub fn picker(key: impl Into<String>, value: impl Into<String>) -> Self {
        Self { key: key.into(), kind: "picker".into(), value: value.into() }
    }
    /// A boolean toggle reporting `"on"`/`"off"`.
    pub fn toggle(key: impl Into<String>, on: bool) -> Self {
        Self { key: key.into(), kind: "toggle".into(), value: if on { "on" } else { "off" }.into() }
    }
}

/// The generic, themed **nav/toolbar** widget (D3b): a workspace/palette/About/
/// live-style control strip plus a 2-row strip of tab headers, all painted in
/// the active [`Theme`] (the clicked/active tab carries the palette accent).
///
/// It is deliberately *not* nornir-specific: tabs are `&[TabItem]`, the left and
/// right slots are host-supplied closures that paint whatever pickers/actions
/// the host wants, and the controls those slots host are declared as
/// [`BarControl`] descriptors so the bar can emit a faithful `state_json`
/// without knowing what a "workspace" or "palette" is.
///
/// # Output
/// * [`TabBar::show`] returns `Option<String>` — the **id of the newly-clicked
///   tab** (`None` if the active tab was unchanged this frame).
/// * [`TabBar::state_json`] / the `state_json` returned alongside describes what
///   was rendered: the tab ids+labels, the active id, and the present
///   pickers/toggles with their values (LAW #6 — "see what the user sees" as data).
///
/// # facett re-home
/// This lives in `facett_ui.rs` (the vendored egui-0.33 module) for the same
/// egui-version reason as the rest of this file: `egui-snarl` pins egui 0.33
/// while `facett` is on 0.34, so a direct facett dependency would link two egui
/// versions. When the versions align this becomes a thin re-export:
/// `pub use facett::TabBar;` (see `.nornir/top-bar-component.md`). Until then it
/// is already [`Theme`]-driven, so the move is a re-home, not a rewrite.
pub struct TabBar<'a> {
    id_salt: &'a str,
    tabs: &'a [TabItem],
    active: &'a str,
    /// Index at which the second row begins (tabs `[0, split)` on row 1, the
    /// rest on row 2). Clamped to `tabs.len()`; the existing viz uses 8.
    split: usize,
    /// Declared controls hosted in the slots — for `state_json` only.
    controls: Vec<BarControl>,
}

impl<'a> TabBar<'a> {
    /// Start a bar for `tabs`, with `active` the currently-selected id, salted by
    /// `id_salt` so multiple bars don't collide. Default split is the midpoint.
    pub fn new(id_salt: &'a str, tabs: &'a [TabItem], active: &'a str) -> Self {
        let split = tabs.len().div_ceil(2);
        Self { id_salt, tabs, active, split, controls: Vec::new() }
    }

    /// Override the row-split index (tabs before it go on row 1, rest on row 2).
    pub fn split_at(mut self, split: usize) -> Self {
        self.split = split.min(self.tabs.len());
        self
    }

    /// Declare a control the host paints in a slot, so it shows up in
    /// `state_json`. Call once per picker/toggle/button (order = report order).
    pub fn with_control(mut self, c: BarControl) -> Self {
        self.controls.push(c);
        self
    }

    /// What this bar will render, as readable data (LAW #6). Stable regardless of
    /// whether [`show`](Self::show) is actually called (headless introspection).
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "tabs": self.tabs.iter().map(|t| serde_json::json!({
                "id": t.id,
                "label": t.label,
                "active": t.id == self.active,
            })).collect::<Vec<_>>(),
            "active": self.active,
            "split": self.split.min(self.tabs.len()),
            "controls": self.controls.iter().map(|c| serde_json::json!({
                "key": c.key,
                "kind": c.kind,
                "value": c.value,
            })).collect::<Vec<_>>(),
        })
    }

    /// Render the bar: the host's `left` slot, then the two tab rows, with the
    /// `right` slot painted on the same control row after the `left` preamble.
    /// Both slots are `FnMut(&mut egui::Ui)` closures so the host paints its own
    /// pickers/actions and mutates its own state. Returns the id of the
    /// newly-clicked tab (`None` if unchanged).
    ///
    /// The active tab is highlighted with the palette accent; the tabs are laid
    /// out on two `horizontal` rows split at [`split_at`](Self::split_at).
    pub fn show(
        &self,
        ui: &mut egui::Ui,
        theme: &Theme,
        mut left: impl FnMut(&mut egui::Ui),
        mut right: impl FnMut(&mut egui::Ui),
    ) -> Option<String> {
        let split = self.split.min(self.tabs.len());
        let mut clicked: Option<String> = None;

        // ── Control row: host pickers/actions (workspace, live, About, palette) ──
        ui.horizontal(|ui| {
            left(ui);
            right(ui);
        });

        // ── Tabs on TWO rows, themed: the active tab carries the palette accent ──
        let mut paint_row = |ui: &mut egui::Ui, items: &[TabItem]| {
            ui.horizontal(|ui| {
                for t in items {
                    let is_active = t.id == self.active;
                    // The active tab paints in the palette accent so a palette
                    // switch re-skins the highlight; selectable_label keeps the
                    // exact label text so robot/AccessKit clicks resolve by label.
                    let mut rt = RichText::new(&t.label);
                    if is_active {
                        rt = rt.color(theme.accent).strong();
                    }
                    if ui.selectable_label(is_active, rt).clicked() && !is_active {
                        clicked = Some(t.id.clone());
                    }
                }
            });
        };
        let _ = &self.id_salt; // reserved for future per-bar id salting
        paint_row(ui, &self.tabs[..split]);
        paint_row(ui, &self.tabs[split..]);

        clicked
    }
}

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

    #[test]
    fn tabbar_state_json_reports_tabs_active_and_controls() {
        let tabs = vec![
            TabItem::new("Timeline", "🧵 Timeline"),
            TabItem::new("DepGraph", "🔗 Dep Graph"),
            TabItem::new("Release", "🚀 Release"),
        ];
        let bar = TabBar::new("top", &tabs, "DepGraph")
            .split_at(2)
            .with_control(BarControl::picker("workspace", "nornir"))
            .with_control(BarControl::picker("palette", "sci-fi"))
            .with_control(BarControl::toggle("live", true));
        let s = bar.state_json();
        // Tabs are reported with id + label + which is active.
        assert_eq!(s["tabs"].as_array().unwrap().len(), 3);
        assert_eq!(s["tabs"][1]["id"], "DepGraph");
        assert_eq!(s["tabs"][1]["label"], "🔗 Dep Graph");
        assert_eq!(s["tabs"][1]["active"], true);
        assert_eq!(s["tabs"][0]["active"], false);
        assert_eq!(s["active"], "DepGraph");
        assert_eq!(s["split"], 2);
        // The pickers/toggle are present in the dump with their values (LAW #6).
        let ctrls = s["controls"].as_array().unwrap();
        assert_eq!(ctrls.len(), 3);
        assert_eq!(ctrls[0]["key"], "workspace");
        assert_eq!(ctrls[0]["kind"], "picker");
        assert_eq!(ctrls[0]["value"], "nornir");
        assert_eq!(ctrls[1]["key"], "palette");
        assert_eq!(ctrls[1]["value"], "sci-fi");
        assert_eq!(ctrls[2]["key"], "live");
        assert_eq!(ctrls[2]["kind"], "toggle");
        assert_eq!(ctrls[2]["value"], "on");
    }

    #[test]
    fn status_chip_vocabulary_is_palette_aware() {
        let d = Theme::default();
        assert_eq!(status_chip(&d, "pass").0, "PASS");
        assert_eq!(status_chip(&d, "PASS").0, "PASS"); // case-insensitive
        assert_eq!(status_chip(&d, "failed").0, "FAIL");
        assert_eq!(status_chip(&d, "skip").0, "SKIP");
        // an unknown verdict's colour is the palette dim — re-skins per theme.
        assert_eq!(status_chip(&d, "???").1, d.text_dim);
        assert_eq!(
            status_chip(&Theme::hugin_noir(), "???").1,
            Theme::hugin_noir().text_dim,
            "unknown chip colour shifts with the palette",
        );
        // a known verdict's colour is the canonical ramp (stable across palettes).
        assert_eq!(status_chip(&d, "pass").1, super::super::facett_theme::GREEN);
    }
}