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//!
11//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
12//! - **[`DepGraphState`]** — the complete observable, serializable, round-trippable
13//!   interaction state: the drilled-in selection, keyed on the node's stable
14//!   **label** (FC-5). The bulk `nodes`+`edges` are *input* held on [`DepGraphView`]
15//!   (their `Color32` is not serde-round-trippable and they are not driver-mutated),
16//!   so they live OUTSIDE the Model — the derived counts/column depth a driver reads
17//!   are published via the form-3 `state_json`.
18//! - **[`Msg`] + [`DepGraphView::update`]** — the single mutation path (FC-2): a node
19//!   is toggle-selected / cleared by its label (the same gesture the box click drives),
20//!   so a headless driver ([`facett_core::harness`]) reaches it too.
21//! - **[`DepGraphView::view`]** — a **pure** paint (FC-9): reads `&self`, paints the
22//!   boxes/arrows/drill-down and *returns* the [`Msg`]s the clicks produced; the
23//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
24
25use std::collections::{BTreeMap, VecDeque};
26
27use egui::{Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2};
28use facett_core::{FacetCaps, theme};
29use serde::{Deserialize, Serialize};
30
31const NODE_W: f32 = 160.0;
32const NODE_H: f32 = 56.0;
33const COL_GAP: f32 = 220.0;
34const ROW_GAP: f32 = 90.0;
35const LEFT_PAD: f32 = 30.0;
36const TOP_PAD: f32 = 40.0;
37
38/// One node: a label, an optional sub-label (a sha, a version…), and a border
39/// colour (the consumer's status policy).
40#[derive(Clone)]
41pub struct DepNode {
42    pub label: String,
43    pub sublabel: Option<String>,
44    pub color: Color32,
45}
46
47/// A directed edge `from → to`, with the connecting items shown on the arrow.
48#[derive(Clone)]
49pub struct DepEdge {
50    pub from: usize,
51    pub to: usize,
52    pub via: Vec<String>,
53}
54
55/// **The complete observable interaction state (FC-1 / FC-3)** of a
56/// [`DepGraphView`]: the drilled-in selection, keyed on the node's stable **label**
57/// (FC-5) so a reorder/insert/delete can't silently re-point it the way a list index
58/// would. The `nodes`/`edges` are *input* on [`DepGraphView`], kept out of this Model.
59#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct DepGraphState {
61    /// Selected node, keyed on its stable **label** (FC-5).
62    pub selected: Option<String>,
63}
64
65/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
66/// driver (or a host) drives the graph through, the same effect a node-box click
67/// produces. Applied by [`DepGraphView::update`].
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Msg {
70    /// Toggle-select a node by its stable **label** (the box click): re-selecting the
71    /// open node clears it.
72    SelectNode(String),
73    /// Clear the drill-in selection.
74    ClearSelection,
75}
76
77/// Side work as data (FC-8). The dep-graph does no I/O — every [`Msg`] mutates only
78/// the in-memory [`DepGraphState`] — so this is uninhabited on purpose.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum Effect {}
81
82/// The layered dep-graph viewer. Implements [`Facet`].
83pub struct DepGraphView {
84    pub title: String,
85    pub nodes: Vec<DepNode>,
86    pub edges: Vec<DepEdge>,
87    /// All observable interaction state (FC-3): the label-keyed selection.
88    state: DepGraphState,
89}
90
91impl DepGraphView {
92    pub fn new(nodes: Vec<DepNode>, edges: Vec<DepEdge>) -> Self {
93        Self { title: "deps".into(), nodes, edges, state: DepGraphState::default() }
94    }
95
96    /// **FC-3** — read the complete observable interaction state.
97    pub fn state(&self) -> &DepGraphState {
98        &self.state
99    }
100
101    /// The currently-selected node's stable label, if any.
102    pub fn selected(&self) -> Option<&str> {
103        self.state.selected.as_deref()
104    }
105
106    /// Index of the currently-selected node (resolved from its stable label).
107    fn selected_idx(&self) -> Option<usize> {
108        self.state.selected.as_deref().and_then(|l| self.nodes.iter().position(|n| n.label == l))
109    }
110    pub fn with_title(mut self, t: impl Into<String>) -> Self {
111        self.title = t.into();
112        self
113    }
114
115    /// Drill into a node by its stable label (or `None` to clear). A thin wrapper
116    /// over the FC-2 [`update`](Self::update) mutation path (the sole way the Model
117    /// changes): deterministic (a *set*, never a toggle).
118    pub fn select(&mut self, node: Option<String>) {
119        let _ = self.update(Msg::ClearSelection);
120        if let Some(n) = node {
121            let _ = self.update(Msg::SelectNode(n));
122        }
123    }
124
125    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
126    /// (always empty) [`Effect`]s the host should run. The canvas node-box click
127    /// routes its [`Msg`] here too, so live == headless.
128    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
129        match msg {
130            // Toggle on the stable label: re-selecting the open node clears it.
131            Msg::SelectNode(label) => {
132                self.state.selected =
133                    if self.state.selected.as_deref() == Some(label.as_str()) { None } else { Some(label) };
134            }
135            Msg::ClearSelection => self.state.selected = None,
136        }
137        Vec::new()
138    }
139
140    /// Column index per node — toposort, deps first (left). Longest-path layering
141    /// so a dependent sits right of all its deps. Cycle → best effort.
142    pub fn columns(&self) -> Vec<usize> {
143        let n = self.nodes.len();
144        let mut indeg = vec![0usize; n];
145        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
146        for e in &self.edges {
147            if e.from < n && e.to < n {
148                // `from` consumes `to` → place `to` first.
149                adj[e.to].push(e.from);
150                indeg[e.from] += 1;
151            }
152        }
153        let mut col = vec![0usize; n];
154        let mut q: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
155        while let Some(r) = q.pop_front() {
156            for &c in &adj[r] {
157                col[c] = col[c].max(col[r] + 1);
158                indeg[c] -= 1;
159                if indeg[c] == 0 {
160                    q.push_back(c);
161                }
162            }
163        }
164        col
165    }
166
167    /// Paint the graph, returning the [`Msg`]s the clicks produced. A thin wrapper
168    /// over the pure [`view`](Self::view) that applies them (kept for direct callers).
169    pub fn show(&mut self, ui: &mut Ui) {
170        let msgs = self.view(ui);
171        for m in msgs {
172            let _ = self.update(m);
173        }
174    }
175
176    /// **FC-9 render** — a **pure** function of `&self`: it paints the boxes/arrows +
177    /// the drill-down panel and *returns* the [`Msg`]s the node clicks produced. It
178    /// MUST NOT mutate the [`DepGraphState`]; the
179    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
180    /// returned messages through [`update`](Self::update).
181    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
182        let mut msgs: Vec<Msg> = Vec::new();
183        if self.nodes.is_empty() {
184            ui.label("no nodes");
185            return msgs;
186        }
187        let col = self.columns();
188        let mut row_of = vec![0usize; self.nodes.len()];
189        let mut per_col: BTreeMap<usize, usize> = BTreeMap::new();
190        for (i, &c) in col.iter().enumerate() {
191            let r = per_col.entry(c).or_insert(0);
192            row_of[i] = *r;
193            *r += 1;
194        }
195        let ncols = col.iter().copied().max().unwrap_or(0) + 1;
196        let nrows = per_col.values().copied().max().unwrap_or(1);
197        let needed_w = LEFT_PAD + ncols as f32 * COL_GAP + NODE_W;
198        let needed_h = TOP_PAD + nrows as f32 * ROW_GAP + NODE_H;
199        let avail = ui.available_size();
200        let canvas = Vec2::new(avail.x.max(needed_w), avail.y.max(needed_h.max(360.0)));
201        let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
202        let th = theme(ui);
203        let painter = ui.painter_at(rect);
204        painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
205        let sel_idx = self.selected_idx();
206
207        let pos_for = |i: usize| {
208            Pos2::new(
209                rect.left() + LEFT_PAD + col[i] as f32 * COL_GAP,
210                rect.top() + TOP_PAD + row_of[i] as f32 * ROW_GAP,
211            )
212        };
213
214        // edges (under nodes)
215        for e in &self.edges {
216            if e.from >= self.nodes.len() || e.to >= self.nodes.len() {
217                continue;
218            }
219            let from = pos_for(e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
220            let to = pos_for(e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
221            draw_arrow(&painter, from, to, th.edge);
222            if !e.via.is_empty() {
223                let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
224                let label = if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via };
225                let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
226                painter.text(mid, Align2::CENTER_BOTTOM, &label, FontId::monospace(10.0), th.text_dim);
227            }
228        }
229
230        // nodes — paint the box, then ride a hit-testable, labelled AccessKit node
231        // keyed on the stable node **label** (FC-4 + FC-5). Click emits a toggle Msg.
232        let base_id = ui.id().with("depgraph-nodes");
233        for (i, node) in self.nodes.iter().enumerate() {
234            let p = pos_for(i);
235            let nrect = Rect::from_min_size(p, Vec2::new(NODE_W, NODE_H));
236            let is_sel = sel_idx == Some(i);
237            let fill = if is_sel { th.accent.linear_multiply(0.2) } else { th.node_fill };
238            painter.rect_filled(nrect, CornerRadius::same(6), fill);
239            let (bw, bc) = if is_sel { (3.0, th.accent) } else { (2.0, node.color) };
240            painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
241            painter.text(p + Vec2::new(NODE_W / 2.0, 14.0), Align2::CENTER_TOP, &node.label, FontId::proportional(15.0), th.text);
242            if let Some(sub) = &node.sublabel {
243                let short: String = sub.chars().take(12).collect();
244                painter.text(p + Vec2::new(NODE_W / 2.0, 34.0), Align2::CENTER_TOP, short, FontId::monospace(11.0), th.text_dim);
245            }
246            let a11y_label = match &node.sublabel {
247                Some(s) => format!("{} · {}", node.label, s),
248                None => node.label.clone(),
249            };
250            let sem = facett_core::a11y::Semantics::button(a11y_label).selected(is_sel);
251            let resp = facett_core::a11y::node(ui, base_id, &node.label, Sense::click(), nrect, sem);
252            if resp.clicked() {
253                // FC-9: emit the Msg instead of mutating `self`.
254                msgs.push(Msg::SelectNode(node.label.clone()));
255            }
256        }
257
258        // drill-down panel
259        if let Some(sel) = self.selected_idx() {
260            let deps: Vec<String> = self
261                .edges
262                .iter()
263                .filter(|e| e.from == sel)
264                .filter_map(|e| self.nodes.get(e.to).map(|n| format!("  → {}   [{}]", n.label, e.via.join(", "))))
265                .collect();
266            let users: Vec<String> = self
267                .edges
268                .iter()
269                .filter(|e| e.to == sel)
270                .filter_map(|e| self.nodes.get(e.from).map(|n| format!("  ← {}   [{}]", n.label, e.via.join(", "))))
271                .collect();
272            let rows = 3 + deps.len() + users.len();
273            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));
274            painter.rect_filled(panel, CornerRadius::same(6), th.panel_bg);
275            painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
276            let mut y = panel.top() + 8.0;
277            let mut put = |s: &str, c: Color32| {
278                painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
279                y += 16.0;
280            };
281            put(&format!("◆ {}  (click again to deselect)", self.nodes[sel].label), th.accent);
282            put(&format!("depends on ({}):", deps.len()), th.text_dim);
283            for d in &deps {
284                put(d, th.text);
285            }
286            put(&format!("used by ({}):", users.len()), th.text_dim);
287            for u in &users {
288                put(u, th.text);
289            }
290        }
291
292        // ── render-lane emit: this pure-view paint path RAN ───────────────────
293        #[cfg(feature = "testmatrix")]
294        facett_core::testmatrix::emit(
295            "facett-graph::DepGraphView::view",
296            "ui_render",
297            !self.nodes.is_empty(),
298            &format!("nodes={} edges={}", self.nodes.len(), self.edges.len()),
299        );
300
301        msgs
302    }
303}
304
305/// Line + arrowhead from `from` to `to` (head pulled back by half a node width).
306pub fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
307    painter.line_segment([from, to], Stroke::new(2.0, color));
308    let dir = (to - from).normalized();
309    let perp = Vec2::new(-dir.y, dir.x);
310    let tip = to - dir * (NODE_W / 2.0 + 4.0);
311    let base = tip - dir * 10.0;
312    painter.add(egui::Shape::convex_polygon(vec![tip, base + perp * 5.0, base - perp * 5.0], color, Stroke::NONE));
313}
314
315impl DepGraphView {
316    /// The copyable text: the selected node's `label` (+ sublabel) when one is
317    /// selected, else every node label, one per line.
318    pub fn copy_text(&self) -> Option<String> {
319        if self.nodes.is_empty() {
320            return None;
321        }
322        if let Some(i) = self.selected_idx() {
323            let n = &self.nodes[i];
324            return Some(match &n.sublabel {
325                Some(s) => format!("{} — {}", n.label, s),
326                None => n.label.clone(),
327            });
328        }
329        Some(self.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
330    }
331}
332
333// ── typed copy (§16) — read-only: selected node label or the label list ───────
334impl facett_core::clip::CopySource for DepGraphView {
335    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
336        use facett_core::clip::ClipKind;
337        &[ClipKind::Text]
338    }
339
340    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
341        self.copy_text().map(facett_core::clip::ClipPayload::Text)
342    }
343}
344
345// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
346impl facett_core::Elm for DepGraphView {
347    type Model = DepGraphState;
348    type Msg = Msg;
349    type Effect = Effect;
350
351    fn title(&self) -> &str {
352        &self.title
353    }
354    fn state(&self) -> &DepGraphState {
355        &self.state
356    }
357    fn update(&mut self, msg: Msg) -> Vec<Effect> {
358        DepGraphView::update(self, msg)
359    }
360    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
361        DepGraphView::view(self, ui)
362    }
363}
364
365// The bridge macro writes `impl Facet for DepGraphView` from the `Elm` impl: `title`,
366// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
367// **Form 3** (`custom_state_json`) because the view publishes a RICHER `state_json`
368// than a plain `serde(state())`: the `nodes`/`edges`/`columns` counts facett-demo's
369// mega_matrix reads are *derived* from the input `nodes`+`edges`, not the selection
370// Model — so the default is suppressed and these EXACT pre-migration keys supplied.
371facett_core::impl_facet_via_elm!(DepGraphView, custom_state_json, {
372    fn state_json(&self) -> serde_json::Value {
373        serde_json::json!({
374            "nodes": self.nodes.len(),
375            "edges": self.edges.len(),
376            "columns": self.columns().iter().copied().max().map(|m| m + 1).unwrap_or(0),
377            "selected": self.state.selected.clone(),
378        })
379    }
380    fn copy(&mut self) -> Option<String> {
381        use facett_core::clip::CopySource as _;
382        self.copy_payload().map(|p| p.as_text())
383    }
384    /// Boxes/arrows/labels are painted from the active [`Theme`]; a node can be
385    /// click-selected (drives the drill-down panel); the layered canvas takes the
386    /// host's `available_size`.
387    fn caps(&self) -> FacetCaps {
388        FacetCaps::NONE.themeable().selectable().resizable().copyable()
389    }
390    fn selection_json(&self) -> serde_json::Value {
391        match self.selected_idx() {
392            Some(i) => serde_json::json!({ "node": self.nodes[i].label }),
393            None => serde_json::Value::Null,
394        }
395    }
396});
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use facett_core::Facet;
402
403    fn node(l: &str) -> DepNode {
404        DepNode { label: l.into(), sublabel: None, color: Color32::GRAY }
405    }
406
407    #[test]
408    fn toposort_columns_and_state() {
409        // a → b → c  (a depends on b depends on c): c leftmost, a rightmost.
410        let nodes = vec![node("a"), node("b"), node("c")];
411        let edges = vec![
412            DepEdge { from: 0, to: 1, via: vec!["x".into()] },
413            DepEdge { from: 1, to: 2, via: vec![] },
414        ];
415        let view = DepGraphView::new(nodes, edges);
416        let cols = view.columns();
417        assert!(cols[2] < cols[1] && cols[1] < cols[0], "deps to the left: {cols:?}");
418        let j = Facet::state_json(&view);
419        assert_eq!(j["nodes"], 3);
420        assert_eq!(j["edges"], 2);
421        assert_eq!(j["columns"], 3);
422        assert!(j["selected"].is_null());
423    }
424
425    #[test]
426    fn typed_copy_is_selected_label_or_the_label_list() {
427        use facett_core::clip::{ClipKind, CopySource};
428        let mut view = DepGraphView::new(vec![node("a"), node("b")], vec![]);
429        // No selection -> every label, one per line.
430        let p = view.copy_payload().expect("populated graph copies");
431        assert_eq!(p.kind(), ClipKind::Text);
432        assert_eq!(p.as_text(), "a\nb");
433        // Selecting narrows the copy to that node's label.
434        view.select(Some("b".into()));
435        assert_eq!(view.copy_payload().unwrap().as_text(), "b");
436    }
437
438    // ── FC-2 → FC-3 as a *headless* property via the core harness ─────────────
439    #[test]
440    fn harness_drives_select_toggle_and_clear() {
441        use facett_core::harness;
442        let mut view = DepGraphView::new(vec![node("a"), node("b"), node("c")], vec![]);
443        // Selecting an existing node is observable in the snapshot.
444        let snap = harness::snapshot(&mut view, [Msg::SelectNode("b".into())]);
445        assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
446        assert_eq!(&snap, view.state(), "the snapshot is a clone of the live state");
447        // Re-selecting the open node toggles it off (the existing click behaviour).
448        let snap = harness::snapshot(&mut view, [Msg::SelectNode("b".into())]);
449        assert_eq!(snap.selected, None, "re-selecting toggles the selection off");
450        // ClearSelection wipes it.
451        let snap = harness::snapshot(&mut view, [Msg::SelectNode("a".into()), Msg::ClearSelection]);
452        assert_eq!(snap.selected, None, "ClearSelection empties the selection");
453    }
454
455    #[test]
456    fn drive_reports_no_effects_and_state_round_trips() {
457        use facett_core::harness;
458        let mut view = DepGraphView::new(vec![node("a"), node("b")], vec![]);
459        // FC-8: the dep-graph does no I/O, so the effect stream is always empty.
460        let effects = harness::drive(&mut view, [Msg::SelectNode("a".into())]);
461        assert!(effects.is_empty(), "FC-8: dep-graph emits no Effects");
462        // FC-3: the observable Model round-trips through serde.
463        let json = serde_json::to_value(view.state()).unwrap();
464        assert_eq!(json["selected"], "a");
465        let back: DepGraphState = serde_json::from_value(json).unwrap();
466        assert_eq!(&back, view.state(), "serde(state) -> state round-trips");
467    }
468
469    // ── FC-5: selection is keyed on the STABLE label, not a volatile index, so a
470    //    re-layout (reordered nodes → different columns/indices) keeps the same node
471    //    selected. ──────────────────────────────────────────────────────────────
472    #[test]
473    fn selection_survives_a_relayout() {
474        use facett_core::harness;
475        // a → b → c: c leftmost, a rightmost. Select "b".
476        let mut view = DepGraphView::new(
477            vec![node("a"), node("b"), node("c")],
478            vec![DepEdge { from: 0, to: 1, via: vec![] }, DepEdge { from: 1, to: 2, via: vec![] }],
479        );
480        harness::drive(&mut view, [Msg::SelectNode("b".into())]);
481        let cols_before = view.columns();
482        let b_idx_before = view.nodes.iter().position(|n| n.label == "b").unwrap();
483        assert_eq!(Facet::selection_json(&view)["node"], "b");
484
485        // Re-layout: reorder the nodes to [c, a, b] and re-point the edges to the same
486        // a→b→c shape. The *indices* and the toposort *columns* both change; the graph
487        // shape does not.
488        view.nodes = vec![node("c"), node("a"), node("b")];
489        view.edges = vec![DepEdge { from: 1, to: 2, via: vec![] }, DepEdge { from: 2, to: 0, via: vec![] }];
490        let b_idx_after = view.nodes.iter().position(|n| n.label == "b").unwrap();
491        let cols_after = view.columns();
492        assert_ne!(b_idx_before, b_idx_after, "b sits at a different index after the re-layout");
493        assert_ne!(cols_before, cols_after, "the toposort columns changed (a real re-layout)");
494
495        // FC-5: the label-keyed selection still resolves to the SAME node "b".
496        assert_eq!(view.state().selected.as_deref(), Some("b"), "selection label is index-independent");
497        assert_eq!(Facet::selection_json(&view)["node"], "b", "selection still resolves to node 'b'");
498        assert_eq!(Facet::state_json(&view)["selected"], "b");
499    }
500
501    #[test]
502    fn headless_render_draws_and_reports_state() {
503        use facett_core::harness;
504        let mut view = DepGraphView::new(
505            vec![node("a"), node("b"), node("c")],
506            vec![DepEdge { from: 0, to: 1, via: vec!["x".into()] }, DepEdge { from: 1, to: 2, via: vec![] }],
507        )
508        .with_title("deps");
509        let r = harness::headless_render(&mut view);
510        assert_eq!(r.title, "deps");
511        assert_eq!(r.state["nodes"], 3);
512        assert_eq!(r.state["columns"], 3);
513        assert!(r.drew(), "a 3-node dep-graph tessellates to vertices");
514    }
515}