Skip to main content

facett_graph/
depgraph.rs

1//! **Dependency-graph** component — a layered DAG. Ported + generalised from
2//! nornir's `src/viz/graph.rs` (phase 1: copied here, nornir still owns its
3//! copy). Nodes toposorted into columns (deps left → dependents right), drawn as
4//! **boxes** with a status-coloured border + optional sub-label; edges as
5//! **arrows** with a `via` label; click a node to select → a drill-down panel of
6//! its deps/dependents.
7//!
8//! Generic: the consumer supplies `(nodes, edges)` (indices + colours), not a
9//! nornir `Timeline`. nornir keeps the *data*; facett draws the *shape*.
10
11use std::collections::{BTreeMap, VecDeque};
12
13use egui::{Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2};
14use facett_core::{Facet, FacetCaps, theme};
15
16const NODE_W: f32 = 160.0;
17const NODE_H: f32 = 56.0;
18const COL_GAP: f32 = 220.0;
19const ROW_GAP: f32 = 90.0;
20const LEFT_PAD: f32 = 30.0;
21const TOP_PAD: f32 = 40.0;
22
23/// One node: a label, an optional sub-label (a sha, a version…), and a border
24/// colour (the consumer's status policy).
25#[derive(Clone)]
26pub struct DepNode {
27    pub label: String,
28    pub sublabel: Option<String>,
29    pub color: Color32,
30}
31
32/// A directed edge `from → to`, with the connecting items shown on the arrow.
33#[derive(Clone)]
34pub struct DepEdge {
35    pub from: usize,
36    pub to: usize,
37    pub via: Vec<String>,
38}
39
40/// The layered dep-graph viewer. Implements [`Facet`].
41pub struct DepGraphView {
42    pub title: String,
43    pub nodes: Vec<DepNode>,
44    pub edges: Vec<DepEdge>,
45    /// Selected node, keyed on its stable **label** (FC-5) — reorder/insert/delete
46    /// can't silently re-point the selection the way a list index would.
47    pub selected: Option<String>,
48}
49
50impl DepGraphView {
51    pub fn new(nodes: Vec<DepNode>, edges: Vec<DepEdge>) -> Self {
52        Self { title: "deps".into(), nodes, edges, selected: None }
53    }
54
55    /// Index of the currently-selected node (resolved from its stable label).
56    fn selected_idx(&self) -> Option<usize> {
57        self.selected.as_deref().and_then(|l| self.nodes.iter().position(|n| n.label == l))
58    }
59    pub fn with_title(mut self, t: impl Into<String>) -> Self {
60        self.title = t.into();
61        self
62    }
63
64    /// Column index per node — toposort, deps first (left). Longest-path layering
65    /// so a dependent sits right of all its deps. Cycle → best effort.
66    pub fn columns(&self) -> Vec<usize> {
67        let n = self.nodes.len();
68        let mut indeg = vec![0usize; n];
69        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
70        for e in &self.edges {
71            if e.from < n && e.to < n {
72                // `from` consumes `to` → place `to` first.
73                adj[e.to].push(e.from);
74                indeg[e.from] += 1;
75            }
76        }
77        let mut col = vec![0usize; n];
78        let mut q: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
79        while let Some(r) = q.pop_front() {
80            for &c in &adj[r] {
81                col[c] = col[c].max(col[r] + 1);
82                indeg[c] -= 1;
83                if indeg[c] == 0 {
84                    q.push_back(c);
85                }
86            }
87        }
88        col
89    }
90
91    pub fn show(&mut self, ui: &mut Ui) {
92        if self.nodes.is_empty() {
93            ui.label("no nodes");
94            return;
95        }
96        let col = self.columns();
97        let mut row_of = vec![0usize; self.nodes.len()];
98        let mut per_col: BTreeMap<usize, usize> = BTreeMap::new();
99        for (i, &c) in col.iter().enumerate() {
100            let r = per_col.entry(c).or_insert(0);
101            row_of[i] = *r;
102            *r += 1;
103        }
104        let ncols = col.iter().copied().max().unwrap_or(0) + 1;
105        let nrows = per_col.values().copied().max().unwrap_or(1);
106        let needed_w = LEFT_PAD + ncols as f32 * COL_GAP + NODE_W;
107        let needed_h = TOP_PAD + nrows as f32 * ROW_GAP + NODE_H;
108        let avail = ui.available_size();
109        let canvas = Vec2::new(avail.x.max(needed_w), avail.y.max(needed_h.max(360.0)));
110        let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
111        let th = theme(ui);
112        let painter = ui.painter_at(rect);
113        painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
114        let sel_idx = self.selected_idx();
115
116        let pos_for = |i: usize| {
117            Pos2::new(
118                rect.left() + LEFT_PAD + col[i] as f32 * COL_GAP,
119                rect.top() + TOP_PAD + row_of[i] as f32 * ROW_GAP,
120            )
121        };
122
123        // edges (under nodes)
124        for e in &self.edges {
125            if e.from >= self.nodes.len() || e.to >= self.nodes.len() {
126                continue;
127            }
128            let from = pos_for(e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
129            let to = pos_for(e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
130            draw_arrow(&painter, from, to, th.edge);
131            if !e.via.is_empty() {
132                let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
133                let label = if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via };
134                let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
135                painter.text(mid, Align2::CENTER_BOTTOM, &label, FontId::monospace(10.0), th.text_dim);
136            }
137        }
138
139        // nodes — paint the box, then ride a hit-testable, labelled AccessKit node
140        // keyed on the stable node **label** (FC-4 + FC-5). Click toggles select.
141        let base_id = ui.id().with("depgraph-nodes");
142        let mut toggle: Option<String> = None;
143        for (i, node) in self.nodes.iter().enumerate() {
144            let p = pos_for(i);
145            let nrect = Rect::from_min_size(p, Vec2::new(NODE_W, NODE_H));
146            let is_sel = sel_idx == Some(i);
147            let fill = if is_sel { th.accent.linear_multiply(0.2) } else { th.node_fill };
148            painter.rect_filled(nrect, CornerRadius::same(6), fill);
149            let (bw, bc) = if is_sel { (3.0, th.accent) } else { (2.0, node.color) };
150            painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
151            painter.text(p + Vec2::new(NODE_W / 2.0, 14.0), Align2::CENTER_TOP, &node.label, FontId::proportional(15.0), th.text);
152            if let Some(sub) = &node.sublabel {
153                let short: String = sub.chars().take(12).collect();
154                painter.text(p + Vec2::new(NODE_W / 2.0, 34.0), Align2::CENTER_TOP, short, FontId::monospace(11.0), th.text_dim);
155            }
156            let a11y_label = match &node.sublabel {
157                Some(s) => format!("{} · {}", node.label, s),
158                None => node.label.clone(),
159            };
160            let sem = facett_core::a11y::Semantics::button(a11y_label).selected(is_sel);
161            let resp = facett_core::a11y::node(ui, base_id, &node.label, Sense::click(), nrect, sem);
162            if resp.clicked() {
163                toggle = Some(node.label.clone());
164            }
165        }
166
167        // click-select (toggle): keyed on the stable label.
168        if let Some(label) = toggle {
169            self.selected = if self.selected.as_deref() == Some(label.as_str()) { None } else { Some(label) };
170        }
171
172        // drill-down panel
173        if let Some(sel) = self.selected_idx() {
174            let deps: Vec<String> = self
175                .edges
176                .iter()
177                .filter(|e| e.from == sel)
178                .filter_map(|e| self.nodes.get(e.to).map(|n| format!("  → {}   [{}]", n.label, e.via.join(", "))))
179                .collect();
180            let users: Vec<String> = self
181                .edges
182                .iter()
183                .filter(|e| e.to == sel)
184                .filter_map(|e| self.nodes.get(e.from).map(|n| format!("  ← {}   [{}]", n.label, e.via.join(", "))))
185                .collect();
186            let rows = 3 + deps.len() + users.len();
187            let panel = Rect::from_min_size(rect.left_top() + Vec2::new(8.0, 8.0), Vec2::new(380.0, rows as f32 * 16.0 + 16.0));
188            painter.rect_filled(panel, CornerRadius::same(6), th.panel_bg);
189            painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
190            let mut y = panel.top() + 8.0;
191            let mut put = |s: &str, c: Color32| {
192                painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
193                y += 16.0;
194            };
195            put(&format!("◆ {}  (click again to deselect)", self.nodes[sel].label), th.accent);
196            put(&format!("depends on ({}):", deps.len()), th.text_dim);
197            for d in &deps {
198                put(d, th.text);
199            }
200            put(&format!("used by ({}):", users.len()), th.text_dim);
201            for u in &users {
202                put(u, th.text);
203            }
204        }
205    }
206}
207
208/// Line + arrowhead from `from` to `to` (head pulled back by half a node width).
209pub fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
210    painter.line_segment([from, to], Stroke::new(2.0, color));
211    let dir = (to - from).normalized();
212    let perp = Vec2::new(-dir.y, dir.x);
213    let tip = to - dir * (NODE_W / 2.0 + 4.0);
214    let base = tip - dir * 10.0;
215    painter.add(egui::Shape::convex_polygon(vec![tip, base + perp * 5.0, base - perp * 5.0], color, Stroke::NONE));
216}
217
218impl DepGraphView {
219    /// The copyable text: the selected node's `label` (+ sublabel) when one is
220    /// selected, else every node label, one per line.
221    pub fn copy_text(&self) -> Option<String> {
222        if self.nodes.is_empty() {
223            return None;
224        }
225        if let Some(i) = self.selected_idx() {
226            let n = &self.nodes[i];
227            return Some(match &n.sublabel {
228                Some(s) => format!("{} — {}", n.label, s),
229                None => n.label.clone(),
230            });
231        }
232        Some(self.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
233    }
234}
235
236// ── typed copy (§16) — read-only: selected node label or the label list ───────
237impl facett_core::clip::CopySource for DepGraphView {
238    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
239        use facett_core::clip::ClipKind;
240        &[ClipKind::Text]
241    }
242
243    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
244        self.copy_text().map(facett_core::clip::ClipPayload::Text)
245    }
246}
247
248impl Facet for DepGraphView {
249    fn title(&self) -> &str {
250        &self.title
251    }
252    fn copy(&mut self) -> Option<String> {
253        use facett_core::clip::CopySource as _;
254        self.copy_payload().map(|p| p.as_text())
255    }
256    fn ui(&mut self, ui: &mut Ui) {
257        self.show(ui);
258        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
259        #[cfg(feature = "testmatrix")]
260        facett_core::testmatrix::emit(
261            "facett-graph::DepGraphView::ui",
262            "ui_render",
263            !self.nodes.is_empty(),
264            &format!("nodes={} edges={}", self.nodes.len(), self.edges.len()),
265        );
266    }
267    fn state_json(&self) -> serde_json::Value {
268        serde_json::json!({
269            "nodes": self.nodes.len(),
270            "edges": self.edges.len(),
271            "columns": self.columns().iter().copied().max().map(|m| m + 1).unwrap_or(0),
272            "selected": self.selected.clone(),
273        })
274    }
275    /// Boxes/arrows/labels are painted from the active [`Theme`]; a node can be
276    /// click-selected (drives the drill-down panel); the layered canvas takes the
277    /// host's `available_size`.
278    fn caps(&self) -> FacetCaps {
279        FacetCaps::NONE.themeable().selectable().resizable().copyable()
280    }
281    fn selection_json(&self) -> serde_json::Value {
282        match self.selected_idx() {
283            Some(i) => serde_json::json!({ "node": self.nodes[i].label }),
284            None => serde_json::Value::Null,
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn node(l: &str) -> DepNode {
294        DepNode { label: l.into(), sublabel: None, color: Color32::GRAY }
295    }
296
297    #[test]
298    fn toposort_columns_and_state() {
299        // a → b → c  (a depends on b depends on c): c leftmost, a rightmost.
300        let nodes = vec![node("a"), node("b"), node("c")];
301        let edges = vec![
302            DepEdge { from: 0, to: 1, via: vec!["x".into()] },
303            DepEdge { from: 1, to: 2, via: vec![] },
304        ];
305        let view = DepGraphView::new(nodes, edges);
306        let cols = view.columns();
307        assert!(cols[2] < cols[1] && cols[1] < cols[0], "deps to the left: {cols:?}");
308        let j = view.state_json();
309        assert_eq!(j["nodes"], 3);
310        assert_eq!(j["edges"], 2);
311        assert_eq!(j["columns"], 3);
312        assert!(j["selected"].is_null());
313    }
314
315    #[test]
316    fn typed_copy_is_selected_label_or_the_label_list() {
317        use facett_core::clip::{ClipKind, CopySource};
318        let mut view = DepGraphView::new(vec![node("a"), node("b")], vec![]);
319        // No selection -> every label, one per line.
320        let p = view.copy_payload().expect("populated graph copies");
321        assert_eq!(p.kind(), ClipKind::Text);
322        assert_eq!(p.as_text(), "a\nb");
323        // Selecting narrows the copy to that node's label.
324        view.selected = Some("b".into());
325        assert_eq!(view.copy_payload().unwrap().as_text(), "b");
326    }
327}