Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/dag_graph.rs

//! **The one graph renderer** — dwarves' DAG surfaces, painted by the shared
//! [`facett_graphview`] engine.
//!
//! ## Why this module exists (L5: reuse, never twin)
//! dwarves has two DAGs nothing drew as a graph: the release convergence and the
//! funnel. Both are the same shape — *nodes with `needs`/`depends-on` edges* — so
//! they get ONE lowering to [`facett_graphview::GraphModel`] here, laid out by the
//! engine's own [`DepGraphLayout`] (longest-path ranking → columns/rows), and both
//! are painted by the engine's [`DecoratedGraphView`] pane. No second layout, no
//! second painter, no per-tab graph code.
//!
//! ## NO NODE CEILING
//! nornir's CallGraph pane hardcodes `MAX_NODES = 160` (`nornir/src/viz/callgraph.rs`)
//! and silently shows a subset beyond it. Nothing here caps the node count: the
//! engine carries a spatial [`PickIndex`](facett_graphview::PickIndex) precisely so a
//! click costs a neighbourhood rather than the dataset, and legibility (label
//! collision + an edge ink budget) is opt-in per pane rather than a hard truncation.
//! What a pane MAY do is report its own size and frame cost — see [`DagPane::report`]
//! — so a slow graph is visible as a measurement instead of hidden behind a cap.

use facett_graphview::{
    Color, Decorations, DepEdge, DepGraphLayout, GraphEdge, GraphModel, GraphNode, LayoutOpts,
    NodeDecoration, Pos,
};

/// Horizontal spacing between dependency ranks (world units). `BOX_W` is 184.
const COL_W: f32 = 236.0;
/// Vertical spacing between siblings in a rank. `BOX_H` is 34.
const ROW_H: f32 = 52.0;

/// One node of a dwarves DAG, in the neutral shape both surfaces lower to.
#[derive(Debug, Clone)]
pub struct DagNode {
    /// The stable id edges reference.
    pub id: String,
    /// What the box says.
    pub label: String,
    /// Box fill — the domain's own status colour.
    pub fill: Color,
    /// An optional emphasis ring (e.g. "this one is blocking").
    pub ring: Option<Color>,
    /// An optional corner badge (e.g. a version or a kind).
    pub badge: Option<String>,
}

/// A DAG ready to lower: nodes plus `from depends on to` edges, by node id.
#[derive(Debug, Clone, Default)]
pub struct DagSpec {
    pub nodes: Vec<DagNode>,
    /// `(from, to)` — `from` depends on / needs `to`. CONTAINMENT in ⚒'s build graph
    /// (a feature on a variant, a setting under a feature): one plain stroke.
    pub edges: Vec<(String, String)>,
    /// `(from, to, label)` — `from` CONSUMES the artifact `to` builds (`--build X`): the
    /// edge runa runs. Drawn DISTINCTLY — a dashed base stroke plus an emphasis stroke
    /// on top (the engine's decoration edge), the double stroke the owner asked for — with
    /// the flag as its label, so the picture and the prompt say the same thing.
    pub consumes: Vec<(String, String, String)>,
}

impl DagSpec {
    /// **Lower to the engine's model, laid out.**
    ///
    /// The layout is the engine's own [`DepGraphLayout`]: a longest-path rank per
    /// node (so every edge points forward) placed on a column/row grid. Nodes the
    /// layout did not place — it walks from roots — are laid out in a trailing
    /// column rather than dropped, because a node that vanished from a picture is
    /// indistinguishable from a node that was never there.
    pub fn lower(&self) -> (GraphModel, Decorations) {
        self.lower_with(&LayoutOpts::default())
    }

    /// [`lower`](Self::lower) with the engine's [`LayoutOpts`] (e.g. a pinned first
    /// column of repos).
    pub fn lower_with(&self, opts: &LayoutOpts) -> (GraphModel, Decorations) {
        let ids: Vec<String> = self.nodes.iter().map(|n| n.id.clone()).collect();
        let dep_edges: Vec<DepEdge> = self
            .edges
            .iter()
            .map(|(f, t)| (f, t))
            .chain(self.consumes.iter().map(|(f, t, _)| (f, t)))
            .map(|(f, t)| DepEdge::new(f.clone(), t.clone(), &[]))
            .collect();
        let laid = DepGraphLayout::build_with(&ids, &dep_edges, false, &Default::default(), opts);

        let mut pos: std::collections::HashMap<&str, Pos> = std::collections::HashMap::new();
        let mut max_col = 0usize;
        for n in &laid.nodes {
            max_col = max_col.max(n.col);
            pos.insert(
                n.repo.as_str(),
                Pos::new(n.col as f32 * COL_W, n.row as f32 * ROW_H),
            );
        }
        // Anything the rank walk did not place goes in its own trailing column,
        // visibly separate rather than silently absent.
        let mut spill = 0usize;
        for n in &self.nodes {
            if !pos.contains_key(n.id.as_str()) {
                pos.insert(
                    n.id.as_str(),
                    Pos::new((max_col + 1) as f32 * COL_W, spill as f32 * ROW_H),
                );
                spill += 1;
            }
        }

        let nodes: Vec<GraphNode> = self
            .nodes
            .iter()
            .map(|n| GraphNode {
                id: n.id.clone(),
                label: n.label.clone(),
                fill: n.fill,
                stroke: Color::rgb(230, 232, 240),
                pos: pos
                    .get(n.id.as_str())
                    .copied()
                    .unwrap_or(Pos::new(0.0, 0.0)),
            })
            .collect();
        let mut edges: Vec<GraphEdge> = self
            .edges
            .iter()
            .map(|(f, t)| GraphEdge {
                from: f.clone(),
                to: t.clone(),
                color: Color::rgba(170, 178, 200, 190),
                dashed: false,
                label: None,
            })
            .collect();
        // A consumption edge is TWO strokes: the dashed base in the model, and the
        // emphasis stroke the engine draws on top of everything else, labelled with the
        // flag. Neither alone would read as "a different kind of edge".
        let mut decorations = Decorations::default();
        for (f, t, label) in &self.consumes {
            edges.push(GraphEdge {
                from: f.clone(),
                to: t.clone(),
                color: Color::rgba(240, 180, 60, 200),
                dashed: true,
                label: None,
            });
            decorations.edges.push(GraphEdge {
                from: f.clone(),
                to: t.clone(),
                color: Color::rgba(240, 180, 60, 255),
                dashed: false,
                label: Some(label.clone()),
            });
        }
        decorations.nodes = self.node_decorations();
        (GraphModel { nodes, edges }, decorations)
    }

    /// The per-node overlay (ring + badge) alone — what [`DagPane::restyle`] swaps
    /// without laying the graph out again.
    pub fn node_decorations(&self) -> std::collections::HashMap<String, NodeDecoration> {
        self.nodes
            .iter()
            .filter(|n| n.ring.is_some() || n.badge.is_some())
            .map(|n| {
                (
                    n.id.clone(),
                    // the badge takes the ring's colour only where it reads on the fill
                    // (a pulsing blue ring on a blue chip did not); else the theme's text
                    NodeDecoration {
                        ring: n.ring,
                        badge: n.badge.clone(),
                        badge_color: n.ring.filter(|r| (luma(*r) - luma(n.fill)).abs() > 60.0),
                        ..Default::default()
                    },
                )
            })
            .collect()
    }
}

/// Relative luminance of a colour, 0–255 (Rec. 709 weights).
fn luma(c: Color) -> f32 {
    0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
}

/// **A measured graph pane.** Wraps the shared [`DecoratedGraphView`] and records
/// what it actually drew: how many nodes and edges, and how long the last frame's
/// `view` call took.
///
/// The measurement is not decoration. A graph pane that quietly caps its input, or
/// quietly costs 700 ms a frame, looks identical to one that does not — so the pane
/// carries its own size and cost and publishes them in `state_json`.
pub struct DagPane {
    view: facett_graphview::DecoratedGraphView,
    node_count: usize,
    edge_count: usize,
    /// Wall time of the last `view` call, microseconds. `None` before the first paint.
    last_frame_us: Option<u128>,
    /// How [`DagSpec::lower_with`] lays the graph out.
    layout: LayoutOpts,
    /// Frame the whole graph on the next paint (the first view, or a new shape).
    fit_pending: bool,
    /// The canvas rect of the last paint (survives a re-lowered model).
    last_rect: Option<egui::Rect>,
    /// The part of that canvas the viewer SAW (canvas ∩ clip): a canvas in a scroll
    /// area or a collapsing section is partly off-screen, and a fit centres on this.
    last_visible: Option<egui::Rect>,
    /// The camera was framed (the first fit ran, or a host placed it): from then on a
    /// new spec keeps it.
    framed: bool,
    /// The node the viewer last clicked: a relayout keeps it where it was on screen
    /// (📌 Stay) or flies it to the centre (🎯 Focus).
    anchor: Option<String>,
    /// 🎯 Focus on this node on the next paint (its new neighbourhood just landed).
    focus_next: Option<String>,
    /// The anchor was clicked since the last relayout (🎯 Focus re-frames once).
    anchor_fresh: bool,
}

/// Margin (px) a fit leaves around the graph.
const FIT_MARGIN: f32 = 24.0;

impl DagPane {
    /// An empty pane titled `title`, with the engine's legibility pass on (label
    /// collision + edge ink budget). Legibility THINS what is drawn; it does not
    /// remove nodes from the model, and `node_count` still reports the whole graph.
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            view: facett_graphview::DecoratedGraphView::new(title).with_legibility(true),
            node_count: 0,
            edge_count: 0,
            last_frame_us: None,
            layout: LayoutOpts::default(),
            fit_pending: false,
            last_rect: None,
            last_visible: None,
            framed: false,
            anchor: None,
            focus_next: None,
            anchor_fresh: false,
        }
    }

    /// **The node the viewer clicked this frame**, taken (the selection is cleared, the
    /// click becomes this pane's anchor): a host that drills in on a click calls this
    /// instead of reading [`selected`](Self::selected). The camera is not touched — in
    /// 📌 Stay a click never moves it; in 🎯 Focus the pane's own flight is under way.
    pub fn take_click(&mut self) -> Option<String> {
        let id = self.selected().map(str::to_string)?;
        self.select(None);
        self.anchor = Some(id.clone());
        self.anchor_fresh = true;
        Some(id)
    }

    /// The viewer's 📌 Stay / 🎯 Focus on this pane.
    pub fn nav_mode(&self) -> facett_core::fly::NavMode {
        self.view.nav_mode()
    }

    /// Set 📌 Stay / 🎯 Focus (a host or a test; the viewer uses the toolbar toggle).
    pub fn set_nav_mode(&mut self, m: facett_core::fly::NavMode) {
        let _ = self.view.update(facett_graphview::decorated_view::Msg::SetNavMode(m));
    }

    /// The 🎯 Focus zoom for node `id` on the last painted canvas (the node at the
    /// centre, it and its children framed, at most the readable focus zoom).
    pub fn focus_zoom(&self, id: &str) -> Option<f32> {
        let r = self.last_visible.or(self.last_rect)?;
        self.view.focus_camera(id, (r.width(), r.height())).map(|c| c.0)
    }

    /// Is the camera flying (🎯 Focus / ⊙ fit)?
    pub fn flying(&self) -> bool {
        self.view.flying()
    }

    /// A message to the engine pane (e.g. `FitView`), as the toolbar would send it.
    pub fn send(&mut self, msg: facett_graphview::decorated_view::Msg) {
        let _ = self.view.update(msg);
    }

    /// The engine layout this pane lowers with ([`LayoutOpts`]; default = the
    /// historical one).
    #[must_use]
    pub fn with_layout(mut self, layout: LayoutOpts) -> Self {
        self.layout = layout;
        self
    }

    /// Change the layout the NEXT [`set_spec`](Self::set_spec) lowers with.
    pub fn set_layout(&mut self, layout: LayoutOpts) {
        self.layout = layout;
    }

    /// Replace the graph and FRAME it on the next paint (zoom + pan so the whole laid-out
    /// graph fills the canvas) — for the first view and for a graph whose node set
    /// changed shape. A plain refresh uses [`set_spec_keep_view`](Self::set_spec_keep_view).
    pub fn set_spec_fit(&mut self, spec: &DagSpec) {
        self.rebuild(spec);
        self.fit_pending = true;
    }

    /// Whether a fit waits for the next paint.
    pub fn fit_pending(&self) -> bool {
        self.fit_pending
    }

    /// The camera `(zoom, pan_x, pan_y)`.
    pub fn camera(&self) -> (f32, f32, f32) {
        let st = self.view.state();
        (st.zoom, st.pan_x, st.pan_y)
    }

    /// Put the camera at `(zoom, pan_x, pan_y)` (a user's pan / zoom, or a host's).
    pub fn set_camera(&mut self, zoom: f32, pan_x: f32, pan_y: f32) {
        self.view.set_camera(zoom, pan_x, pan_y);
        self.framed = true;
        self.fit_pending = false;
    }

    /// Every node's laid-out world position, by id.
    pub fn positions(&self) -> Vec<(String, Pos)> {
        self.view
            .state()
            .model
            .nodes
            .iter()
            .map(|n| (n.id.clone(), n.pos))
            .collect()
    }

    /// A world position on screen (the last paint's canvas + the camera).
    pub fn world_to_screen(&self, p: Pos) -> Option<egui::Pos2> {
        self.view.world_to_screen(p)
    }

    /// The canvas rect of the last paint.
    pub fn last_rect(&self) -> Option<egui::Rect> {
        self.last_rect
    }

    /// The part of the last painted canvas the viewer saw (canvas ∩ clip).
    pub fn last_visible(&self) -> Option<egui::Rect> {
        self.last_visible
    }

    /// **Where the graph is on screen**: the box of every node's chip (centre ± half a
    /// chip) after the last paint — what a robot compares with
    /// [`last_visible`](Self::last_visible) ("centred, fully visible").
    pub fn screen_box(&self) -> Option<egui::Rect> {
        let (z, ..) = self.camera();
        let half = egui::vec2(facett_graphview::model::BOX_W, facett_graphview::model::BOX_H) * 0.5 * z;
        let visible = self.view.visible_ids();
        let mut b: Option<egui::Rect> = None;
        for (id, p) in self.positions() {
            if !visible.contains(&id) {
                continue;
            }
            let c = self.world_to_screen(p)?;
            let r = egui::Rect::from_center_size(c, half * 2.0);
            b = Some(b.map_or(r, |b| b.union(r)));
        }
        b
    }

    /// **Replace the graph.** The whole spec is loaded — there is no node cap here.
    ///
    /// The FIRST graph a pane gets is framed on its first paint — fitted and centred in
    /// the visible part of the canvas. Every later one KEEPS the camera: a new layout
    /// (new data, a picked variant, an opened node) never re-frames — a graph that
    /// re-fits on every click is the "it moves to the right" the owner saw. The node the
    /// viewer clicked keeps its spot (📌 Stay) or flies to the centre once its new
    /// neighbourhood is laid out (🎯 Focus). Only ⊙ fit frames again.
    pub fn set_spec(&mut self, spec: &DagSpec) {
        if self.framed {
            self.keep_view_with(spec);
        } else {
            self.rebuild(spec);
            self.fit_pending = self.node_count > 0;
        }
    }

    /// The model swap itself (camera at the engine's default).
    fn rebuild(&mut self, spec: &DagSpec) {
        let (model, decorations) = spec.lower_with(&self.layout);
        self.node_count = model.nodes.len();
        self.edge_count = model.edges.len();
        let title = facett_core::Facet::title(&self.view).to_string();
        // the viewer's 📌 Stay / 🎯 Focus survives the rebuild
        let mode = self.view.nav_mode_set().then(|| self.view.nav_mode());
        self.view = facett_graphview::DecoratedGraphView::new(title)
            .with_legibility(true)
            .with_model(model)
            .with_decorations(decorations);
        if let Some(m) = mode {
            self.set_nav_mode(m);
        }
    }

    /// Replace the graph, KEEPING the camera and the selection — a live graph that is
    /// lowered again every few seconds must not jump back to the origin.
    ///
    /// **The anchor stays put**: when the viewer clicked a node ([`take_click`]) and the
    /// new layout moved it, the pan follows it, so it is exactly where it was on screen
    /// (📌 Stay — a click never moves anything); in 🎯 Focus it flies to the centre with
    /// its new neighbourhood on the next paint.
    ///
    /// [`take_click`]: Self::take_click
    pub fn set_spec_keep_view(&mut self, spec: &DagSpec) {
        self.set_spec(spec);
    }

    fn keep_view_with(&mut self, spec: &DagSpec) {
        let st = self.view.state();
        let (zoom, mut pan_x, mut pan_y, selected) = (st.zoom, st.pan_x, st.pan_y, st.selected.clone());
        let pos_of = |v: &facett_graphview::DecoratedGraphView, id: &str| v.state().model.nodes.iter().find(|n| n.id == id).map(|n| n.pos);
        let before = self.anchor.as_deref().and_then(|a| pos_of(&self.view, a));
        self.rebuild(spec);
        if let (Some(a), Some(old)) = (self.anchor.clone(), before) {
            if let Some(new) = pos_of(&self.view, &a) {
                pan_x += (old.x - new.x) * zoom;
                pan_y += (old.y - new.y) * zoom;
                if self.nav_mode() == facett_core::fly::NavMode::Focus && std::mem::take(&mut self.anchor_fresh) {
                    self.focus_next = Some(a);
                }
            }
        }
        self.view.set_camera(zoom, pan_x, pan_y);
        self.view.select(selected);
    }

    /// Swap only the node overlay (rings + badges) of `spec`, keeping the model, the
    /// layout, the camera and the selection — how a live mark pulses frame by frame.
    /// The spec's emphasis edges are not re-derived: pass the spec the model came from.
    pub fn restyle(&mut self, spec: &DagSpec) {
        let mut decorations = self.view.state().decorations.clone();
        decorations.nodes = spec.node_decorations();
        let view = std::mem::replace(
            &mut self.view,
            facett_graphview::DecoratedGraphView::new(""),
        );
        self.view = view.with_decorations(decorations);
    }

    /// Paint, timing the call.
    ///
    /// A pending fit frames the graph in the canvas: before the paint when the canvas
    /// size is known from an earlier one, else right after the first paint (with a
    /// repaint, so the framed picture is the next frame).
    pub fn ui(&mut self, ui: &mut egui::Ui) {
        if let (Some(id), Some(r), Some(v)) = (self.focus_next.take(), self.last_rect, self.last_visible) {
            let now = ui.input(|i| i.time);
            let offset = (v.center().x - r.center().x, v.center().y - r.center().y);
            self.send(facett_graphview::decorated_view::Msg::FocusOn { id, now, viewport: (v.width(), v.height()), offset });
        }
        if self.fit_pending {
            self.fit_now();
        }
        let before = self.selected().map(str::to_string);
        let t0 = std::time::Instant::now();
        facett_core::Facet::ui(&mut self.view, ui);
        self.last_frame_us = Some(t0.elapsed().as_micros());
        if let Some(r) = self.view.last_paint_rect() {
            self.last_rect = Some(r);
            let v = r.intersect(ui.clip_rect());
            self.last_visible = Some(if v.width() > 1.0 && v.height() > 1.0 { v } else { r });
        }
        // a click that selected a node makes it the anchor a relayout keeps in place
        let after = self.selected().map(str::to_string);
        if after.is_some() && after != before {
            self.anchor = after;
            self.anchor_fresh = true;
        }
        if self.fit_pending && self.fit_now() {
            ui.ctx().request_repaint();
        }
    }

    /// Frame the whole graph, centred in the visible part of the last canvas; `false`
    /// when there is nothing to frame yet (no nodes, never painted).
    fn fit_now(&mut self) -> bool {
        let (Some(r), Some(v)) = (self.last_rect, self.last_visible) else { return false };
        if self.node_count == 0 {
            return false;
        }
        let Some((z, px, py)) = self.view.fit_camera((v.width(), v.height()), FIT_MARGIN) else { return false };
        self.view.set_camera(z, px + v.center().x - r.center().x, py + v.center().y - r.center().y);
        self.fit_pending = false;
        self.framed = true;
        true
    }

    /// Nodes in the model (the WHOLE graph — never a truncated subset).
    pub fn node_count(&self) -> usize {
        self.node_count
    }

    /// Edges in the model.
    pub fn edge_count(&self) -> usize {
        self.edge_count
    }

    /// Wall time of the last painted frame, microseconds.
    pub fn last_frame_us(&self) -> Option<u128> {
        self.last_frame_us
    }

    /// The measurement, as data — what was drawn and what it cost.
    pub fn report(&self) -> serde_json::Value {
        serde_json::json!({
            "nodes": self.node_count,
            "edges": self.edge_count,
            "last_frame_us": self.last_frame_us,
            // There is none. Stated, so its absence is a claim rather than a silence.
            "node_cap": serde_json::Value::Null,
            "camera": { "zoom": self.camera().0, "pan_x": self.camera().1, "pan_y": self.camera().2, "fit_pending": self.fit_pending, "flying": self.flying() },
            "nav_mode": format!("{:?}", self.nav_mode()),
            "anchor": self.anchor,
            "legibility": {
                "labels_placed": self.view.legibility().labels_placed,
                "labels_suppressed": self.view.legibility().labels_suppressed,
                "labels_considered": self.view.legibility().labels_considered,
                "edges_drawn": self.view.legibility().edges_drawn,
                "edges_thinned": self.view.legibility().edges_thinned,
            },
        })
    }

    /// The wrapped engine pane's own observable state.
    pub fn state_json(&self) -> serde_json::Value {
        facett_core::Facet::state_json(&self.view)
    }

    /// The selected node's stable id, if one is selected (a canvas click, or [`Self::select`]).
    pub fn selected(&self) -> Option<&str> {
        self.view.state().selected.as_deref()
    }

    /// Select a node by id (`None` clears) — the robot-/host-addressable twin of a click.
    pub fn select(&mut self, id: Option<String>) {
        self.view.select(id);
    }

    /// Fold a node: its subtree (forward edges) hides, the node stays.
    pub fn fold(&mut self, id: &str) {
        let _ = self
            .view
            .update(facett_graphview::decorated_view::Msg::Fold(id.to_string()));
    }

    /// Unfold a node in place: its subtree returns, the node and its ancestors stay.
    pub fn unfold(&mut self, id: &str) {
        let _ = self
            .view
            .update(facett_graphview::decorated_view::Msg::Unfold(
                id.to_string(),
            ));
    }

    /// Whether a node is unfolded.
    pub fn is_expanded(&self, id: &str) -> bool {
        self.view.nav().is_expanded(id)
    }

    /// The node ids on screen this frame — folded subtrees are absent.
    pub fn visible_ids(&self) -> std::collections::BTreeSet<String> {
        self.view.visible_ids()
    }
}

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

    fn chain(n: usize) -> DagSpec {
        let nodes = (0..n)
            .map(|i| DagNode {
                id: format!("n{i}"),
                label: format!("node {i}"),
                fill: Color::rgb(60, 90, 160),
                ring: None,
                badge: None,
            })
            .collect();
        let edges = (1..n)
            .map(|i| (format!("n{}", i - 1), format!("n{i}")))
            .collect();
        DagSpec {
            nodes,
            edges,
            consumes: Vec::new(),
        }
    }

    /// The lowering places every node — including one no edge touches. A node that
    /// disappeared from a picture is indistinguishable from a node that never
    /// existed, which is the whole failure class this work is deleting.
    #[test]
    fn every_node_is_placed_including_the_isolated_one() {
        let mut spec = chain(4);
        spec.nodes.push(DagNode {
            id: "orphan".into(),
            label: "orphan".into(),
            fill: Color::GRAY,
            ring: None,
            badge: None,
        });
        let (model, _) = spec.lower();
        assert_eq!(model.nodes.len(), 5, "no node is dropped by the layout");
        assert_eq!(model.edges.len(), 3);
        // Ranks really separate: a chain must not collapse onto one column.
        let xs: std::collections::BTreeSet<i64> =
            model.nodes.iter().map(|n| n.pos.x as i64).collect();
        assert!(
            xs.len() >= 4,
            "the chain is laid out across ranks, got {xs:?}"
        );
    }

    /// A badge in the ring's colour only where that reads on the fill: the tree's
    /// compiling ring (light blue) on its blue chip falls back to the theme text; an
    /// amber ring on a blue chip keeps its colour.
    #[test]
    fn a_badge_keeps_the_ring_colour_only_where_it_reads() {
        let node = |id: &str, fill: Color, ring: Color| DagNode {
            id: id.into(),
            label: id.into(),
            fill,
            ring: Some(ring),
            badge: Some("×2".into()),
        };
        let spec = DagSpec {
            nodes: vec![
                node(
                    "compiling",
                    Color::rgb(40, 104, 214),
                    Color::rgba(90, 160, 255, 200),
                ),
                node(
                    "blocking",
                    Color::rgb(60, 90, 160),
                    Color::rgb(0xe0, 0xa8, 0x00),
                ),
            ],
            edges: Vec::new(),
            consumes: Vec::new(),
        };
        let d = spec.node_decorations();
        assert_eq!(
            d["compiling"].badge_color, None,
            "blue on blue: the theme text instead"
        );
        assert_eq!(
            d["blocking"].badge_color,
            Some(Color::rgb(0xe0, 0xa8, 0x00))
        );
        assert_eq!(
            d["compiling"].ring,
            Some(Color::rgba(90, 160, 255, 200)),
            "the ring itself stays"
        );
    }

    /// **RED-when-broken: there is no node ceiling.** nornir's CallGraph pane caps at
    /// `MAX_NODES = 160` and shows a subset beyond it. This pane loads what it is
    /// given: 4 000 nodes in, 4 000 nodes in the model.
    #[test]
    fn the_pane_has_no_node_cap() {
        let mut p = DagPane::new("cap-probe");
        let spec = chain(4_000);
        p.set_spec(&spec);
        assert_eq!(p.node_count(), 4_000, "every node reaches the model");
        assert_eq!(p.edge_count(), 3_999);
        assert!(
            p.report()["node_cap"].is_null(),
            "and the pane says it has no cap"
        );
    }
}

/// A scale probe for the DAG pane — `cargo test -p dwarves-gui scale_probe -- --ignored
/// --nocapture`. Ignored (it is a measurement, not a gate) and prints node count + the
/// per-frame cost at several sizes, mirroring facett-graphview's own `scale_probe`
/// example. Numbers are only as clean as the box: on a contended machine they are an
/// upper bound, not the engine's floor.
#[cfg(test)]
mod scale_probe {
    use super::*;

    fn chain(n: usize) -> DagSpec {
        let nodes = (0..n)
            .map(|i| DagNode {
                id: format!("n{i}"),
                label: format!("crate-{i} L0.1.{i} S0.1.{i} C0.1.{i}"),
                fill: Color::rgb(60, 90, 160),
                ring: if i % 3 == 0 {
                    Some(Color::rgb(0xe0, 0xa8, 0x00))
                } else {
                    None
                },
                badge: if i % 3 == 0 {
                    Some("needs-release".into())
                } else {
                    None
                },
            })
            .collect();
        // a DAG, not just a chain: each node depends on up to 3 earlier ones
        let mut edges = Vec::new();
        for i in 1..n {
            edges.push((format!("n{i}"), format!("n{}", i - 1)));
            if i >= 5 {
                edges.push((format!("n{i}"), format!("n{}", i - 5)));
            }
            if i >= 17 {
                edges.push((format!("n{i}"), format!("n{}", i - 17)));
            }
        }
        DagSpec {
            nodes,
            edges,
            consumes: Vec::new(),
        }
    }

    #[test]
    #[ignore]
    fn measure_scale() {
        for n in [12usize, 160, 1000, 5000] {
            let spec = chain(n);
            let mut pane = DagPane::new("scale");
            let t_lower = std::time::Instant::now();
            pane.set_spec(&spec);
            let lower_us = t_lower.elapsed().as_micros();
            let cell = std::cell::RefCell::new(pane);
            let mut h = egui_kittest::Harness::builder()
                .with_size(egui::vec2(1600.0, 1000.0))
                .build_ui(|ui| {
                    cell.borrow_mut().ui(ui);
                });
            // warm + measure a few frames, take the min
            let mut best = u128::MAX;
            for _ in 0..6 {
                h.step();
                best = best.min(cell.borrow().last_frame_us().unwrap_or(0));
            }
            let c = cell.borrow();
            eprintln!("SCALE n={:>5} nodes_rendered={:>5} edges={:>6} lower={:>6}us frame_min={:>7}us ({:.2}ms)",
                n, c.node_count(), c.edge_count(), lower_us, best, best as f64/1000.0);
        }
    }
}