facett-buildthing/src/graph_pane.rs
//! **The build GRAPH pane** — what a variant compiles in, drawn.
//!
//! The plugin returns a [`BuildGraph`]: nodes, edges, roles. No SVG, no egui type
//! anywhere near the wasm boundary. This module is the other half — it maps that data
//! onto **`facett_graphpan::GraphModel`** and hands it to the SHARED
//! [`facett_graphpan::draw_graph`], which already owns pan, scroll-zoom,
//! click-to-select and downstream highlighting.
//!
//! There is no layout code here beyond assigning columns by node kind, and no renderer
//! at all: a second graph renderer in this constellation would be the fourth twin of
//! the session. What this file adds is the domain mapping — which kind gets which
//! colour, which edge is dashed — and the one thing the picture exists for:
//!
//! > **a `Reaches` edge is dashed and an `Embeds` edge is solid.**
//!
//! That single visual distinction is the fat/thin story. `holger full` embeds skade
//! (solid: it is *in the binary*); `holger mini` reaches it (dashed: it must be
//! *running*). Measured on 2026-07-22, the same distinction in dwarves is a 6.3 MB
//! thin binary against a 227 MB fat one — 35.9×. A table states that; a picture shows
//! it before you have read a word.
//!
//! Clicking a variant node returns the variant it [`selects`](nornir_build_thing::plugin::GraphNode::selects),
//! so the graph is not an illustration beside the menu — it *is* a way to pick a build.
use std::collections::HashMap;
use egui::{Color32, Pos2, Ui};
use facett_graphpan::{Decorations, GraphEdge, GraphModel, GraphNode, GraphView};
use nornir_build_thing::plugin::{BuildGraph, EdgeKind, NodeKind};
/// Which nodes the graph shows. **The filter is the interaction**: a build's gated
/// file list is long, and the finding is almost never "all of it" — it is "what does
/// this variant leave out".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Filter {
/// The build itself: variant, steps, artifacts, embeds/reaches. No files.
#[default]
Build,
/// The build plus every gated source file.
Files,
/// Only the files this variant GATES OUT — the code that is not in the binary.
/// This is the view that finds a dead subsystem.
Excluded,
}
impl Filter {
/// Stable label.
pub fn as_str(self) -> &'static str {
match self {
Filter::Build => "build",
Filter::Files => "files",
Filter::Excluded => "excluded",
}
}
/// The tab caption.
pub fn caption(self) -> &'static str {
match self {
Filter::Build => "Build",
Filter::Files => "+ files",
Filter::Excluded => "excluded only",
}
}
}
/// Categorical colour per node kind — one hue per *kind*, never per magnitude. Drawn
/// from the same family the other facett graph panes use so a reader moving between
/// them is not relearning a palette.
fn node_fill(kind: NodeKind) -> Color32 {
match kind {
NodeKind::Variant => Color32::from_rgb(0x15, 0x65, 0xc0),
NodeKind::Step => Color32::from_rgb(0x37, 0x47, 0x4f),
NodeKind::Artifact => Color32::from_rgb(0x00, 0x69, 0x5c),
// The two that carry the whole point, deliberately far apart in hue.
NodeKind::Embedded => Color32::from_rgb(0x6a, 0x1b, 0x9a),
NodeKind::Reached => Color32::from_rgb(0x8d, 0x6e, 0x00),
// A source file's own colour is neutral; its INCLUSION is what carries meaning
// and that is painted by `file_fill` from the node's detail.
NodeKind::SourceFile => Color32::from_rgb(0x45, 0x4a, 0x4d),
}
}
/// A gated file's colour, by whether this variant actually compiles it.
///
/// Three distinguishable states, never two: **in the binary**, **excluded from the
/// binary**, and **not decided**. Painting "gated out" and "undecided" the same way
/// would be the conflation this whole layer exists to expose.
fn file_fill(detail: Option<&str>) -> Color32 {
let d = detail.unwrap_or_default();
if d.contains("[gated-out]") {
Color32::from_rgb(0xc6, 0x28, 0x28)
} else if d.contains("[compiled-in]") {
Color32::from_rgb(0x00, 0x69, 0x5c)
} else if d.contains("[undecided]") {
Color32::from_rgb(0x6a, 0x1b, 0x9a)
} else {
Color32::from_rgb(0x45, 0x4a, 0x4d)
}
}
/// True when a node's detail marks it excluded from this build.
fn is_excluded(detail: Option<&str>) -> bool {
detail.unwrap_or_default().contains("[gated-out]")
}
fn edge_color(kind: EdgeKind) -> Color32 {
match kind {
EdgeKind::Plan => Color32::from_rgb(0x60, 0x6a, 0x70),
EdgeKind::Produces => Color32::from_rgb(0x00, 0x69, 0x5c),
EdgeKind::Embeds => Color32::from_rgb(0x6a, 0x1b, 0x9a),
EdgeKind::Reaches => Color32::from_rgb(0x8d, 0x6e, 0x00),
EdgeKind::Gates => Color32::from_rgb(0x60, 0x50, 0x70),
}
}
/// Which column a kind sits in — the only "layout" this file does. Left to right is
/// the story order: *this variant* → *what it does* → *what comes out*, with the
/// composition siblings banked below the root where they belong to it visually.
fn column(kind: NodeKind) -> usize {
match kind {
NodeKind::Variant => 0,
NodeKind::Embedded | NodeKind::Reached => 1,
NodeKind::Step => 2,
NodeKind::Artifact => 3,
// Files bank furthest right: there are many, and they must not push the
// build's own spine off the left of the view.
NodeKind::SourceFile => 4,
}
}
/// Map a [`BuildGraph`] onto the shared renderer's model, laying nodes out in columns
/// by kind and rows by order of appearance.
pub fn to_graph_model(g: &BuildGraph) -> GraphModel {
to_graph_model_filtered(g, Filter::Files)
}
/// Map a [`BuildGraph`] onto the renderer's model, showing only the nodes `filter`
/// admits. Edges whose endpoint was filtered away are dropped WITH their node — a
/// filter that left an edge pointing at nothing would break the same invariant
/// `dangling_edges()` protects on the data side.
pub fn to_graph_model_filtered(g: &BuildGraph, filter: Filter) -> GraphModel {
const COL_W: f32 = 210.0;
const ROW_H: f32 = 64.0;
let keep = |n: &nornir_build_thing::plugin::GraphNode| -> bool {
match (filter, n.kind) {
(Filter::Build, NodeKind::SourceFile) => false,
(Filter::Excluded, NodeKind::SourceFile) => is_excluded(n.detail.as_deref()),
// In `Excluded`, the build's own spine stays so the files have something
// to hang from — an orphan file list is not a graph.
_ => true,
}
};
let kept: std::collections::BTreeSet<&str> =
g.nodes.iter().filter(|n| keep(n)).map(|n| n.id.as_str()).collect();
let mut per_column: HashMap<usize, usize> = HashMap::new();
let nodes = g
.nodes
.iter()
.filter(|n| keep(n))
.map(|n| {
let col = column(n.kind);
let row = *per_column.entry(col).and_modify(|r| *r += 1).or_insert(0);
GraphNode {
id: n.id.clone(),
label: n.label.clone(),
fill: if n.kind == NodeKind::SourceFile {
file_fill(n.detail.as_deref())
} else {
node_fill(n.kind)
},
stroke: if n.kind == NodeKind::SourceFile {
file_fill(n.detail.as_deref())
} else {
node_fill(n.kind)
},
pos: Pos2::new(col as f32 * COL_W, row as f32 * ROW_H),
}
})
.collect();
let edges = g
.edges
.iter()
.filter(|e| kept.contains(e.from.as_str()) && kept.contains(e.to.as_str()))
.map(|e| GraphEdge {
from: e.from.clone(),
to: e.to.clone(),
color: edge_color(e.kind),
// The one distinction the picture exists for: a runtime dependency is
// dashed, a compiled-in one is solid.
dashed: e.kind.dashed(),
label: e.label.clone(),
})
.collect();
GraphModel { nodes, edges }
}
/// The graph pane's own state: the shared view (pan / zoom / selection) plus the
/// graph currently being shown.
#[derive(Default)]
pub struct BuildGraphPane {
/// The graph the plugin returned.
pub graph: BuildGraph,
/// Pan / zoom / selection, owned by the shared renderer.
pub view: GraphView,
/// Which nodes to show — the interaction the owner asked for.
pub filter: Filter,
/// The variant a click asked to select, taken by the host each frame.
pending_select: Option<String>,
}
impl BuildGraphPane {
/// A pane over one graph.
pub fn new(graph: BuildGraph) -> Self {
BuildGraphPane { graph, ..Default::default() }
}
/// Show a different variant's graph (the menu pane selected something).
pub fn set_graph(&mut self, graph: BuildGraph) {
self.graph = graph;
}
/// Record a pending selection — the deck's test seam, standing in for the click
/// `ui` would have recorded. `#[doc(hidden)]`: it is a seam, not API.
#[doc(hidden)]
pub fn pending_select_for_test(&mut self, variant: &str) {
self.pending_select = Some(variant.to_string());
}
/// Take the variant a node click asked to select, if any. The host applies it —
/// this pane never mutates the selection itself, so the menu stays the one owner.
pub fn take_selection(&mut self) -> Option<String> {
self.pending_select.take()
}
/// `(compiled_in, gated_out, undecided)` over the graph's source-file nodes.
pub fn gated_counts(&self) -> (usize, usize, usize) {
let mut t = (0, 0, 0);
for n in self.graph.nodes.iter().filter(|n| n.kind == NodeKind::SourceFile) {
let d = n.detail.as_deref().unwrap_or_default();
if d.contains("[gated-out]") {
t.1 += 1;
} else if d.contains("[undecided]") {
t.2 += 1;
} else {
t.0 += 1;
}
}
t
}
/// Set the filter (the headless seam).
pub fn set_filter(&mut self, f: Filter) {
self.filter = f;
}
/// The pane title, for a deck.
pub fn title(&self) -> String {
if self.graph.root.is_empty() {
"Build graph".to_string()
} else {
format!("Build graph — {}", self.graph.root)
}
}
/// **Draw.** Delegates to the shared `facett_graphpan::draw_graph`, which owns
/// pan, scroll-zoom, click-to-select and downstream highlighting. A clicked
/// variant node records the build it selects.
pub fn ui(&mut self, ui: &mut Ui) {
if self.graph.nodes.is_empty() {
ui.label(
egui::RichText::new(
"no graph — this variant is not declared by the build thing (an empty \
graph is a missing declaration, not an empty build)",
)
.small(),
);
return;
}
// THE FILTER — the interaction. "Show me what this build leaves out" is the
// question that finds a dead subsystem, and it is one click.
let gated = self.gated_counts();
ui.horizontal_wrapped(|ui| {
for f in [Filter::Build, Filter::Files, Filter::Excluded] {
if ui.selectable_label(self.filter == f, f.caption()).clicked() {
self.filter = f;
}
}
if gated.2 > 0 {
ui.label(
egui::RichText::new(format!(
"· {} in · {} EXCLUDED · {} undecided",
gated.0, gated.1, gated.2
))
.small(),
);
} else {
ui.label(
egui::RichText::new(format!("· {} in · {} EXCLUDED", gated.0, gated.1)).small(),
);
}
});
// The legend earns its space: it is the key to the one distinction that matters.
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("━━ compiled IN (in the binary)").small().color(edge_color(EdgeKind::Embeds)));
ui.label(egui::RichText::new("╌╌ reached over gRPC (must be RUNNING)").small().color(edge_color(EdgeKind::Reaches)));
});
if self.filter == Filter::Excluded && gated.1 == 0 {
ui.label(
egui::RichText::new(
"this variant gates NO file out — every gated file it knows about is \
compiled in",
)
.small(),
);
}
let model = to_graph_model_filtered(&self.graph, self.filter);
// Colours come from the LIVE egui visuals, not from constants here, so the
// pane follows whatever theme facett-core's `look` presets put in force.
let v = ui.visuals().clone();
let response = facett_graphpan::draw_graph(
ui,
&model,
&Decorations::default(),
&mut self.view,
v.extreme_bg_color,
v.text_color(),
v.selection.bg_fill,
v.weak_text_color(),
);
if let Some(id) = response.clicked_node
&& let Some(sel) = self
.graph
.nodes
.iter()
.find(|n| n.id == id)
.and_then(|n| n.selects.clone())
{
self.pending_select = Some(sel);
}
}
/// The observable state (L4) — asserted by a robot rather than eyeballed.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"component": "BuildGraphPane",
"repo": self.graph.repo,
"root": self.graph.root,
"node_count": self.graph.nodes.len(),
"edge_count": self.graph.edges.len(),
"embedded": self.graph.embedded(),
"reached": self.graph.reached(),
"dangling_edges": self.graph.dangling_edges(),
"filter": self.filter.as_str(),
"gated_compiled_in": self.gated_counts().0,
"gated_out": self.gated_counts().1,
"gated_undecided": self.gated_counts().2,
"pending_select": self.pending_select,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::plugin::{BuildGraph, GraphEdge as BgEdge, GraphNode as BgNode};
fn fat() -> BuildGraph {
BuildGraph {
repo: "holger".into(),
root: "full".into(),
nodes: vec![
BgNode {
id: "variant:full".into(),
label: "full".into(),
kind: NodeKind::Variant,
detail: Some("embeds skade".into()),
selects: Some("full".into()),
},
BgNode {
id: "embeds:skade".into(),
label: "skade".into(),
kind: NodeKind::Embedded,
detail: None,
selects: None,
},
],
edges: vec![BgEdge {
from: "variant:full".into(),
to: "embeds:skade".into(),
kind: EdgeKind::Embeds,
label: None,
}],
}
}
/// **The distinction the picture exists for.** A compiled-in sibling is a solid
/// edge; one reached over gRPC is dashed. Everything else about the render is
/// the shared renderer's business.
#[test]
fn embeds_is_solid_and_reaches_is_dashed() {
let m = to_graph_model(&fat());
assert_eq!(m.edges.len(), 1);
assert!(!m.edges[0].dashed, "compiled IN is solid — it is in the binary");
let mut thin = fat();
thin.edges[0].kind = EdgeKind::Reaches;
let m = to_graph_model(&thin);
assert!(m.edges[0].dashed, "reached over gRPC is dashed — it must be RUNNING");
assert_ne!(
m.edges[0].color,
edge_color(EdgeKind::Embeds),
"and the two are not the same colour either"
);
}
/// Nodes land in distinct columns by kind, so the mapping produces a layout the
/// shared renderer can draw without every node stacking at the origin.
#[test]
fn nodes_are_laid_out_in_columns_by_kind() {
let m = to_graph_model(&fat());
let root = m.nodes.iter().find(|n| n.id == "variant:full").unwrap();
let sib = m.nodes.iter().find(|n| n.id == "embeds:skade").unwrap();
assert!(sib.pos.x > root.pos.x, "the sibling sits to the right of the variant");
assert_ne!(root.fill, sib.fill, "kinds are categorically coloured");
}
/// A clicked variant node yields the build to select — the payoff for the graph
/// being data rather than a picture.
#[test]
fn the_pane_reports_a_click_as_a_variant_to_select() {
let mut p = BuildGraphPane::new(fat());
assert_eq!(p.take_selection(), None, "nothing clicked yet");
// Simulate what `ui` does with a click on the root node.
p.pending_select = p.graph.nodes[0].selects.clone();
assert_eq!(p.take_selection().as_deref(), Some("full"));
assert_eq!(p.take_selection(), None, "taken once, then cleared");
assert!(p.title().contains("full"));
}
/// An empty graph is a REPORTED state — a missing declaration, never an empty
/// build silently drawn as nothing.
#[test]
fn an_empty_graph_states_that_it_is_a_missing_declaration() {
let p = BuildGraphPane::default();
let s = p.state_json();
assert_eq!(s["node_count"], 0);
assert_eq!(s["component"], "BuildGraphPane");
assert!(s["dangling_edges"].as_array().unwrap().is_empty());
}
/// **The filter is the interaction.** `Build` hides the file layer, `Files` shows
/// it, `Excluded` shows only what this variant leaves OUT — the view that finds a
/// dead subsystem.
#[test]
fn the_filter_narrows_to_what_this_build_leaves_out() {
use nornir_build_thing::plugin::{GraphEdge as BgEdge, GraphNode as BgNode};
let mut g = fat();
let file = |name: &str, state: &str| BgNode {
id: format!("file:{name}"),
label: name.into(),
kind: NodeKind::SourceFile,
detail: Some(format!("src/{name} — feature = \"x\" [{state}]")),
selects: None,
};
g.nodes.push(file("in.rs", "compiled-in"));
g.nodes.push(file("out.rs", "gated-out"));
g.nodes.push(file("maybe.rs", "undecided"));
for n in ["in.rs", "out.rs", "maybe.rs"] {
g.edges.push(BgEdge {
from: "variant:full".into(),
to: format!("file:{n}"),
kind: EdgeKind::Gates,
label: None,
});
}
let mut p = BuildGraphPane::new(g);
assert_eq!(p.gated_counts(), (1, 1, 1), "three states, counted apart");
// Build: no files at all.
p.set_filter(Filter::Build);
let m = to_graph_model_filtered(&p.graph, Filter::Build);
assert_eq!(m.nodes.len(), 2, "variant + embedded sibling only");
assert!(m.edges.iter().all(|e| !e.from.starts_with("file:") && !e.to.starts_with("file:")));
// Files: all three.
let m = to_graph_model_filtered(&p.graph, Filter::Files);
assert_eq!(m.nodes.len(), 5);
// Excluded: only the gated-out one, and the build spine it hangs from.
let m = to_graph_model_filtered(&p.graph, Filter::Excluded);
let files: Vec<&str> =
m.nodes.iter().filter(|n| n.id.starts_with("file:")).map(|n| n.label.as_str()).collect();
assert_eq!(files, vec!["out.rs"], "only what this build LEAVES OUT");
// No edge may point at a node the filter removed.
let ids: std::collections::BTreeSet<&str> = m.nodes.iter().map(|n| n.id.as_str()).collect();
assert!(
m.edges.iter().all(|e| ids.contains(e.from.as_str()) && ids.contains(e.to.as_str())),
"a filter must not leave an edge pointing at nothing"
);
}
/// The three inclusion states get three different colours. Painting "gated out"
/// and "undecided" alike is the conflation this layer exists to expose.
#[test]
fn the_three_inclusion_states_are_three_colours() {
let c_in = file_fill(Some("x [compiled-in]"));
let c_out = file_fill(Some("x [gated-out]"));
let c_und = file_fill(Some("x [undecided]"));
assert_ne!(c_in, c_out);
assert_ne!(c_out, c_und);
assert_ne!(c_in, c_und);
assert!(is_excluded(Some("x [gated-out]")));
assert!(!is_excluded(Some("x [compiled-in]")));
assert!(!is_excluded(None), "no detail is not an exclusion claim");
}
#[test]
fn state_json_names_what_is_compiled_in_and_what_must_be_running() {
let s = BuildGraphPane::new(fat()).state_json();
assert_eq!(s["embedded"], serde_json::json!(["skade"]));
assert_eq!(s["reached"], serde_json::json!([]));
assert_eq!(s["root"], "full");
}
}