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