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