1use 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#[derive(Clone)]
41pub struct DepNode {
42 pub label: String,
43 pub sublabel: Option<String>,
44 pub color: Color32,
45}
46
47#[derive(Clone)]
49pub struct DepEdge {
50 pub from: usize,
51 pub to: usize,
52 pub via: Vec<String>,
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct DepGraphState {
61 pub selected: Option<String>,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Msg {
70 SelectNode(String),
73 ClearSelection,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum Effect {}
81
82pub struct DepGraphView {
84 pub title: String,
85 pub nodes: Vec<DepNode>,
86 pub edges: Vec<DepEdge>,
87 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 pub fn state(&self) -> &DepGraphState {
98 &self.state
99 }
100
101 pub fn selected(&self) -> Option<&str> {
103 self.state.selected.as_deref()
104 }
105
106 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 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 pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
129 match msg {
130 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 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 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 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 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 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 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 msgs.push(Msg::SelectNode(node.label.clone()));
255 }
256 }
257
258 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 #[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
305pub 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 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
333impl 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
345impl 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
365facett_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 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 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 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 view.select(Some("b".into()));
435 assert_eq!(view.copy_payload().unwrap().as_text(), "b");
436 }
437
438 #[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 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 let snap = harness::snapshot(&mut view, [Msg::SelectNode("b".into())]);
449 assert_eq!(snap.selected, None, "re-selecting toggles the selection off");
450 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 let effects = harness::drive(&mut view, [Msg::SelectNode("a".into())]);
461 assert!(effects.is_empty(), "FC-8: dep-graph emits no Effects");
462 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 #[test]
473 fn selection_survives_a_relayout() {
474 use facett_core::harness;
475 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 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 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}