src/viz/facett_ui.rs
//! **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 still lives in the viz crate (the egui blocker is GONE — this is next)
//!
//! 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.
//!
//! The mechanical blocker that used to justify keeping them here — nornir's viz
//! pinned to **egui 0.33** (the highest `egui-snarl 0.9` supported) against
//! facett's **0.34**, so a direct dependency would have linked two eguis into one
//! binary — **no longer exists**: both sides are on egui **0.34.3** and
//! `eframe::egui` IS the crate facett paints with. [`super::facett_theme`] and
//! [`super::graph_render`] have already collapsed into thin re-exports of
//! `facett_core::theme` / `facett-graphpan` on the strength of that. These
//! widgets have no facett home to re-export *yet* (there is no `facett` chip /
//! sparkline / zebra component that matches these shapes), so they stay — but as
//! an OPEN L5 item, not a blocked one. See
//! `.nornir/theming-and-facett-migration.md`; the move is a re-home, not a
//! rewrite, since every widget already takes a [`Theme`].
//!
//! 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, ThemeExt};
/// 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_f32, 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_f32, 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_f32 } else { 1.5_f32 }, 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 ──
// `horizontal_wrapped` (not `horizontal`) so a row that outgrows the bar
// width WRAPS onto further lines instead of overflowing past the panel's
// clip rect — a clipped tab is unclickable (its interact + AccessKit rect
// fall outside the viewport, so a robot/AccessKit click never lands). With
// 26 tabs the second row overran 1400px and the rightmost tabs (🤖 Claude,
// 🧠 Models) were unreachable; wrapping keeps every tab on-screen.
let mut paint_row = |ui: &mut egui::Ui, items: &[TabItem]| {
ui.horizontal_wrapped(|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);
}
}