nornir 0.4.19

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! **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,
    );
}

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

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