Skip to main content

facett_syschart/
lib.rs

1//! **facett-syschart** — a **system-map chart**: a handful of *free-positioned*
2//! peer nodes (not a layered DAG) joined by edges, each carrying a **badge**
3//! (a count / status number) and, when selected, an **inline expandable detail
4//! panel**. Click a node to select it; its detail renders in a panel under the
5//! canvas. The host owns *what* a node's detail is (a string here; richer hosts
6//! draw their own widgets after `ui()` keyed off [`SystemChart::selected`]).
7//!
8//! This is the piece [`facett_core::draw`]/[`facett_graph::GraphView`] don't
9//! cover — those paint a *non-interactive* `Scene`; `DepGraphView` is a
10//! *layered* DAG with a fixed deps/dependents drill-down. A "monitoring map" of
11//! a few peer systems (a PKI/OIDC/Nexus triangle, a service mesh) wants
12//! deterministic positions, a per-node badge, click-select, and a free-form
13//! detail panel — that's [`SystemChart`].
14//!
15//! Like every facett component it implements [`Facet`]: `title` / `ui` /
16//! `state_json` (every node, badge, edge + the selection), so it drops into a
17//! `FacetDeck` and is robot-testable through `facett_core::harness` — drive
18//! [`SystemChart::select`] headlessly and assert `state_json`.
19
20use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
21use facett_core::clip::{ClipKind, ClipPayload, CopySource};
22use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
23
24/// One system in the map. Positions are **normalized** (`0.0..=1.0` of the
25/// canvas), so the layout is resolution-independent and deterministic — ideal
26/// for headless rendering and screenshots.
27#[derive(Clone, Debug)]
28pub struct SysNode {
29    /// Stable id (used for selection + edges). Unique within a chart.
30    pub id: String,
31    /// Human label drawn next to the node.
32    pub label: String,
33    /// Node colour (the host picks the policy — by status, by hash, …).
34    pub color: Color32,
35    /// A small count/status badge drawn on the node (e.g. an event count).
36    pub badge: u64,
37    /// Normalized position in the canvas (`0,0` = top-left, `1,1` = bottom-right).
38    pub pos: (f32, f32),
39    /// Free-form detail shown in the inline panel when this node is selected.
40    /// Hosts that draw richer detail can leave this empty and render their own
41    /// widgets after `ui()` keyed off [`SystemChart::selected`].
42    pub detail: String,
43}
44
45impl SysNode {
46    pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
47        Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
48    }
49    pub fn badge(mut self, n: u64) -> Self {
50        self.badge = n;
51        self
52    }
53    pub fn detail(mut self, d: impl Into<String>) -> Self {
54        self.detail = d.into();
55        self
56    }
57}
58
59/// An undirected link between two node ids.
60#[derive(Clone, Debug)]
61pub struct SysEdge {
62    pub a: String,
63    pub b: String,
64}
65
66impl SysEdge {
67    pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
68        Self { a: a.into(), b: b.into() }
69    }
70}
71
72/// The system-map chart: nodes + edges + the current selection.
73pub struct SystemChart {
74    pub title: String,
75    pub nodes: Vec<SysNode>,
76    pub edges: Vec<SysEdge>,
77    /// Stable id of the selected node (drives the detail panel). Keyed on the
78    /// domain id ([`SysNode::id`]), not an enumeration index, so reorder/insert/
79    /// delete preserves the selection (FC-5).
80    selected: Option<String>,
81    /// Height (px) the node canvas takes before the detail panel.
82    canvas_h: f32,
83}
84
85const NODE_R: f32 = 16.0;
86
87impl SystemChart {
88    pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
89        Self { title: title.into(), nodes, edges, selected: None, canvas_h: 280.0 }
90    }
91    pub fn with_canvas_height(mut self, h: f32) -> Self {
92        self.canvas_h = h;
93        self
94    }
95
96    fn index_of(&self, id: &str) -> Option<usize> {
97        self.nodes.iter().position(|n| n.id == id)
98    }
99
100    /// Index of the currently-selected node, resolved from its stable id.
101    fn selected_idx(&self) -> Option<usize> {
102        self.selected.as_deref().and_then(|id| self.index_of(id))
103    }
104
105    /// Select a node by id (headless-test + host entry point). Selecting the
106    /// already-selected node deselects it. Unknown id is a no-op.
107    pub fn select(&mut self, id: &str) {
108        if self.index_of(id).is_some() {
109            self.selected = if self.selected.as_deref() == Some(id) {
110                None
111            } else {
112                Some(id.to_string())
113            };
114        }
115    }
116    /// Clear the selection.
117    pub fn clear_selection(&mut self) {
118        self.selected = None;
119    }
120    /// The selected node's id, if any.
121    pub fn selected(&self) -> Option<&str> {
122        self.selected.as_deref()
123    }
124
125    /// Update a node's badge by id (e.g. a live event count). No-op if unknown.
126    pub fn set_badge(&mut self, id: &str, badge: u64) {
127        if let Some(i) = self.index_of(id) {
128            self.nodes[i].badge = badge;
129        }
130    }
131    /// Update a node's detail text by id. No-op if unknown.
132    pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
133        if let Some(i) = self.index_of(id) {
134            self.nodes[i].detail = detail.into();
135        }
136    }
137
138    /// **Test/host hook (additive).** The public, return-asserted view of the
139    /// private [`center`](Self::center) layout node — the absolute pixel centre
140    /// node `i`'s circle is painted at inside `rect`. Exposed so the syschart
141    /// call-chain matrix can assert the *layout/place* stage (finite, confined to
142    /// the padded inner rect, monotone in the normalized pos) without a painter.
143    /// Calls the **same** private fn `ui` paints with — additive, no behaviour
144    /// change. The node radius the layout pads for is [`SystemChart::NODE_R`].
145    pub fn node_center(&self, i: usize, rect: Rect) -> Pos2 {
146        self.center(i, rect)
147    }
148    /// The node radius (px) the free-position layout pads the canvas by, exposed so
149    /// the matrix can assert nodes land inside `rect` by exactly this inset.
150    pub const NODE_R: f32 = NODE_R;
151
152    /// Absolute pixel centre of node `i` inside `rect`.
153    fn center(&self, i: usize, rect: Rect) -> Pos2 {
154        let (nx, ny) = self.nodes[i].pos;
155        let pad = NODE_R + 6.0;
156        let inner = Rect::from_min_max(
157            rect.min + vec2(pad, pad),
158            rect.max - vec2(pad, pad),
159        );
160        Pos2::new(
161            inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
162            inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
163        )
164    }
165}
166
167impl SystemChart {
168    /// The copyable text: the selected node's `label · detail` (badge in a suffix)
169    /// when a node is selected, else the whole node list as a TSV rectangle
170    /// (`id \t label \t badge`).
171    pub fn copy_text(&self) -> Option<String> {
172        if self.nodes.is_empty() {
173            return None;
174        }
175        if let Some(n) = self.selected().and_then(|id| self.nodes.iter().find(|n| n.id == id)) {
176            let mut s = format!("{} (badge {})", n.label, n.badge);
177            if !n.detail.is_empty() {
178                s.push('\n');
179                s.push_str(&n.detail);
180            }
181            return Some(s);
182        }
183        let mut out = String::from("id\tlabel\tbadge");
184        for n in &self.nodes {
185            out.push('\n');
186            out.push_str(&format!("{}\t{}\t{}", n.id, n.label, n.badge));
187        }
188        Some(out)
189    }
190}
191
192// ── typed copy (§16) — read-only: selected node text or node-list rows ─────────
193impl CopySource for SystemChart {
194    fn copy_kinds(&self) -> &[ClipKind] {
195        &[ClipKind::Text]
196    }
197
198    fn copy_payload(&self) -> Option<ClipPayload> {
199        self.copy_text().map(ClipPayload::Text)
200    }
201}
202
203impl Facet for SystemChart {
204    fn title(&self) -> &str {
205        &self.title
206    }
207
208    fn copy(&mut self) -> Option<String> {
209        self.copy_payload().map(|p| p.as_text())
210    }
211
212    fn ui(&mut self, ui: &mut Ui) {
213        let th = theme(ui);
214        // ── node canvas ──────────────────────────────────────────────────────
215        let canvas = vec2(ui.available_width(), self.canvas_h.min(ui.available_height().max(self.canvas_h)));
216        let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
217        let base = ui.id().with("syschart-node");
218        let painter = ui.painter_at(rect);
219
220        let centers: Vec<Pos2> = (0..self.nodes.len()).map(|i| self.center(i, rect)).collect();
221
222        // edges first (under the nodes)
223        for e in &self.edges {
224            if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
225                painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5, th.edge));
226            }
227        }
228
229        // nodes — paint as before, plus an AccessKit node per element (FC-4) keyed
230        // on the stable domain id (FC-5). Collect the click into a local and apply
231        // selection AFTER the loop to avoid borrowing `self` mutably mid-iteration.
232        let sel_id = self.selected.clone();
233        let mut toggle: Option<String> = None;
234        for (i, node) in self.nodes.iter().enumerate() {
235            let c = centers[i];
236            let selected = sel_id.as_deref() == Some(node.id.as_str());
237            let r = if selected { NODE_R + 3.0 } else { NODE_R };
238            // Elevation shadow under the raised node chip (Windows-pronounced /
239            // macOS-restrained, per the active NativeFeel) — painted BEFORE the fill
240            // so it sits behind the node. No-op under Device (effects-off).
241            facett_core::look::elevation_shadow(ui, Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), r);
242            painter.circle_filled(c, r, node.color);
243            let ring = if selected { th.accent } else { th.node_stroke };
244            painter.circle_stroke(c, r, Stroke::new(if selected { 2.5 } else { 1.0 }, ring));
245            // badge inside the node
246            painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
247            // label under the node
248            painter.text(
249                c + vec2(0.0, r + 2.0),
250                Align2::CENTER_TOP,
251                &node.label,
252                FontId::proportional(11.0),
253                th.text,
254            );
255
256            // AccessKit hit-test node over the node's circle (FC-4): role=Button,
257            // label = "{label} · {badge} events", numeric_value = badge, selected.
258            let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
259            // ── native-feel cues (mac/windows parity): the Windows reveal glow on
260            // hover + the platform focus ring/rect on the selected node. Both read
261            // the active Theme's NativeFeel through the shared `look::feel` helpers,
262            // so this node follows the platform preset (no-op on Neutral/Device).
263            facett_core::look::reveal_on_hover(ui, node_rect, r);
264            facett_core::look::apply_focus_ring(ui, node_rect, selected, r);
265            let label = format!("{} · {} events", node.label, node.badge);
266            let hit = a11y_node(
267                ui,
268                base,
269                &node.id,
270                Sense::click(),
271                node_rect,
272                Semantics::button(label).value(node.badge as f64).selected(selected),
273            );
274            if hit.clicked() {
275                toggle = Some(node.id.clone());
276            }
277        }
278
279        // click-select: toggle the clicked node by its stable id.
280        if let Some(id) = toggle {
281            self.select(&id);
282        }
283
284        // ── inline detail panel ──────────────────────────────────────────────
285        ui.separator();
286        match self.selected_idx() {
287            None => {
288                ui.weak("Click a node to expand its detail.");
289            }
290            Some(i) => {
291                let node = &self.nodes[i];
292                ui.horizontal(|ui| {
293                    ui.strong(&node.label);
294                    ui.weak(format!("· {} events", node.badge));
295                });
296                if node.detail.is_empty() {
297                    ui.weak("(no detail)");
298                } else {
299                    for line in node.detail.lines() {
300                        ui.monospace(line);
301                    }
302                }
303            }
304        }
305
306        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
307        #[cfg(feature = "testmatrix")]
308        facett_core::testmatrix::emit(
309            "facett-syschart::SystemChart::ui",
310            "ui_render",
311            !self.nodes.is_empty(),
312            &format!("nodes={} edges={}", self.nodes.len(), self.edges.len()),
313        );
314    }
315
316    fn state_json(&self) -> serde_json::Value {
317        serde_json::json!({
318            "nodes": self.nodes.iter().map(|n| serde_json::json!({
319                "id": n.id,
320                "label": n.label,
321                "badge": n.badge,
322                "pos": [n.pos.0, n.pos.1],
323                "has_detail": !n.detail.is_empty(),
324            })).collect::<Vec<_>>(),
325            "edges": self.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
326            "selected": self.selected(),
327        })
328    }
329
330    fn selection_json(&self) -> serde_json::Value {
331        match self.selected() {
332            Some(id) => serde_json::json!(id),
333            None => serde_json::Value::Null,
334        }
335    }
336
337    /// Painted with the active `Theme` (nodes/edges/text/accent ring), and its
338    /// canvas takes the host's available width — themeable + resizable.
339    fn caps(&self) -> FacetCaps {
340        FacetCaps::NONE.themeable().resizable().selectable().copyable()
341    }
342
343    /// Opt into typed downcast so a host (e.g. the demo's robot-UI node-select
344    /// toolbar) can forward a selection to [`SystemChart::select`] when this chart
345    /// lives boxed inside a `FacetDeck`.
346    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
347        Some(self)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353
354    #[test]
355    fn typed_copy_is_selected_node_or_node_list_rows() {
356        use facett_core::clip::{ClipKind, CopySource};
357        let mut c = SystemChart::new(
358            "sys",
359            vec![
360                SysNode::new("pki", "PKI", Color32::WHITE, (0.1, 0.1)).badge(3),
361                SysNode::new("oidc", "OIDC", Color32::WHITE, (0.9, 0.1)).badge(7),
362            ],
363            vec![SysEdge::new("pki", "oidc")],
364        );
365        // No selection -> the node list as a TSV rectangle.
366        let p = c.copy_payload().unwrap();
367        assert_eq!(p.kind(), ClipKind::Text);
368        assert!(p.as_text().starts_with("id\tlabel\tbadge"), "{}", p.as_text());
369        assert!(p.as_text().contains("\npki\tPKI\t3"));
370        // Selecting narrows to that node's label + badge.
371        c.select("oidc");
372        assert_eq!(c.copy_payload().unwrap().as_text(), "OIDC (badge 7)");
373        assert!(SystemChart::new("empty", vec![], vec![]).copy_payload().is_none());
374    }
375
376    use super::*;
377    use facett_core::harness;
378
379    fn sample() -> SystemChart {
380        let nodes = vec![
381            SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
382            SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
383            SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
384        ];
385        let edges = vec![
386            SysEdge::new("pki", "oidc"),
387            SysEdge::new("pki", "nexus"),
388            SysEdge::new("oidc", "nexus"),
389        ];
390        SystemChart::new("System Map", nodes, edges)
391    }
392
393    #[test]
394    fn select_toggles_and_reports() {
395        let mut c = sample();
396        assert_eq!(c.selected(), None);
397        c.select("oidc");
398        assert_eq!(c.selected(), Some("oidc"));
399        c.select("oidc"); // toggle off
400        assert_eq!(c.selected(), None);
401        c.select("nope"); // unknown → no-op
402        assert_eq!(c.selected(), None);
403    }
404
405    #[test]
406    fn set_badge_and_detail_mutate_named_node() {
407        let mut c = sample();
408        c.set_badge("nexus", 42);
409        c.set_detail("nexus", "repo: maven-releases");
410        let nexus = c.nodes.iter().find(|n| n.id == "nexus").unwrap();
411        assert_eq!(nexus.badge, 42);
412        assert!(nexus.detail.contains("maven-releases"));
413    }
414
415    #[test]
416    fn state_json_carries_every_node_edge_and_selection() {
417        let mut c = sample();
418        c.select("pki");
419        let j = c.state_json();
420        assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
421        assert_eq!(j["edges"].as_array().unwrap().len(), 3);
422        assert_eq!(j["selected"], "pki");
423        // badge + detail flags surfaced for robot assertions
424        let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
425        assert_eq!(pki["badge"], 3);
426        assert_eq!(pki["has_detail"], true);
427    }
428
429    #[test]
430    fn headless_render_draws_and_selection_shows_detail() {
431        // Inject a real chart + a real selection, render offscreen, assert it
432        // both DREW pixels and reported the selected node in its state — the
433        // inject-input/assert-output law, no display.
434        let mut c = sample();
435        c.select("pki");
436        let r = harness::headless_render(&mut c);
437        assert_eq!(r.title, "System Map");
438        assert!(r.drew(), "a 3-node chart should tessellate to vertices");
439        assert_eq!(r.state["selected"], "pki");
440        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
441    }
442
443    #[test]
444    fn caps_advertise_selectable_themeable_resizable() {
445        let caps = sample().caps();
446        assert!(caps.selectable);
447        assert!(caps.themeable);
448        assert!(caps.resizable);
449        assert!(!caps.scalable, "syschart has no zoom yet");
450    }
451}