1use std::collections::{BTreeSet, HashMap, VecDeque};
15
16use facett_core::clip::{ClipKind, ClipPayload, CopySource};
17use facett_core::{FacetCaps, Semantics};
18use serde::{Deserialize, Serialize};
19
20use crate::model::{Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos, BOX_H, BOX_W};
21
22pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
26 let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
27 for e in &model.edges {
28 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
29 }
30 let mut lit: BTreeSet<String> = BTreeSet::new();
31 let mut q: VecDeque<&str> = VecDeque::new();
32 lit.insert(seed.to_string());
33 q.push_back(seed);
34 while let Some(cur) = q.pop_front() {
35 if let Some(outs) = adj.get(cur) {
36 for &nxt in outs {
37 if lit.insert(nxt.to_string()) {
38 q.push_back(nxt);
39 }
40 }
41 }
42 }
43 lit
44}
45
46#[derive(Clone, Debug, PartialEq)]
50pub enum Msg {
51 Select(Option<String>),
53 PanBy(f32, f32),
55 ZoomBy(f32),
57 Fit,
59}
60
61#[derive(Clone, Debug, PartialEq)]
65pub enum Effect {}
66
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct GraphViewState {
75 pub model: GraphModel,
77 pub decorations: Decorations,
79 pub selected: Option<String>,
81 pub pan_x: f32,
83 pub pan_y: f32,
85 pub zoom: f32,
87}
88
89impl Default for GraphViewState {
90 fn default() -> Self {
91 Self {
92 model: GraphModel::default(),
93 decorations: Decorations::default(),
94 selected: None,
95 pan_x: 0.0,
96 pan_y: 0.0,
97 zoom: 1.0,
98 }
99 }
100}
101
102pub struct DecoratedGraphView {
104 title: String,
105 state: GraphViewState,
107}
108
109impl DecoratedGraphView {
110 pub fn new(title: impl Into<String>) -> Self {
112 Self { title: title.into(), state: GraphViewState::default() }
113 }
114
115 pub fn with_model(mut self, model: GraphModel) -> Self {
117 self.state.model = model;
118 self
119 }
120
121 pub fn with_decorations(mut self, decorations: Decorations) -> Self {
123 self.state.decorations = decorations;
124 self
125 }
126
127 pub fn set_title(&mut self, title: impl Into<String>) {
129 self.title = title.into();
130 }
131
132 pub fn state(&self) -> &GraphViewState {
134 &self.state
135 }
136
137 pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
141 match msg {
142 Msg::Select(id) => self.state.selected = id,
143 Msg::PanBy(dx, dy) => {
144 self.state.pan_x += dx;
145 self.state.pan_y += dy;
146 }
147 Msg::ZoomBy(scroll) => {
148 self.state.zoom = (self.state.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
149 }
150 Msg::Fit => {
151 self.state.pan_x = 0.0;
152 self.state.pan_y = 0.0;
153 self.state.zoom = 1.0;
154 }
155 }
156 Vec::new()
157 }
158
159 pub fn fit(&mut self) {
162 let _ = self.update(Msg::Fit);
163 }
164
165 pub fn select(&mut self, id: Option<String>) {
168 let _ = self.update(Msg::Select(id));
169 }
170
171 pub fn lit_set(&self) -> BTreeSet<String> {
173 self.state.selected.as_deref().map(|s| downstream_of(&self.state.model, s)).unwrap_or_default()
174 }
175
176 pub fn local() -> Self {
181 let n = |id: &str, label: &str, x: f32, y: f32, fill: Color| GraphNode {
182 id: id.into(),
183 label: label.into(),
184 fill,
185 stroke: Color::WHITE,
186 pos: Pos::new(x, y),
187 };
188 let e = |from: &str, to: &str, dashed: bool| GraphEdge {
189 from: from.into(),
190 to: to.into(),
191 color: Color::rgb(200, 200, 220),
192 dashed,
193 label: None,
194 };
195 let model = GraphModel {
196 nodes: vec![
197 n("a", "alpha", 0.0, 0.0, Color::rgb(60, 90, 160)),
198 n("b", "beta", 260.0, -90.0, Color::rgb(60, 140, 90)),
199 n("c", "gamma", 260.0, 90.0, Color::rgb(160, 90, 60)),
200 n("d", "delta", 520.0, 0.0, Color::rgb(120, 90, 160)),
201 ],
202 edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
203 };
204 let mut nodes = HashMap::new();
205 nodes.insert(
206 "a".to_string(),
207 NodeDecoration { ring: Some(Color::rgb(90, 200, 140)), ..Default::default() },
208 );
209 nodes.insert(
210 "b".to_string(),
211 NodeDecoration {
212 badge: Some("⚠".into()),
213 badge_color: Some(Color::rgb(230, 180, 60)),
214 ..Default::default()
215 },
216 );
217 let decorations = Decorations {
218 nodes,
219 edges: vec![GraphEdge {
220 from: "d".into(),
221 to: "a".into(),
222 color: Color::rgb(230, 90, 90),
223 dashed: true,
224 label: Some("cut".into()),
225 }],
226 };
227 Self::new("DecoratedGraph").with_model(model).with_decorations(decorations)
228 }
229
230 pub fn remote() -> Self {
233 let mut v = Self::local();
234 v.title = "DecoratedGraph (remote)".into();
235 v
236 }
237
238 fn col(c: Color) -> egui::Color32 {
239 c.into()
240 }
241}
242
243impl DecoratedGraphView {
244 pub fn copy_text(&self) -> Option<String> {
247 if self.state.model.nodes.is_empty() {
248 return None;
249 }
250 if let Some(id) = self.state.selected.as_deref() {
251 if let Some(n) = self.state.model.nodes.iter().find(|n| n.id == id) {
252 return Some(n.label.clone());
253 }
254 }
255 Some(self.state.model.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
256 }
257}
258
259impl CopySource for DecoratedGraphView {
261 fn copy_kinds(&self) -> &[ClipKind] {
262 &[ClipKind::Text]
263 }
264 fn copy_payload(&self) -> Option<ClipPayload> {
265 self.copy_text().map(ClipPayload::Text)
266 }
267}
268
269impl DecoratedGraphView {
270 pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
277 let mut msgs: Vec<Msg> = Vec::new();
278 let th = facett_core::theme(ui);
279 let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };
282
283 ui.horizontal_wrapped(|ui| {
285 ui.label(
286 egui::RichText::new(format!("{} nodes · {} edges", self.state.model.nodes.len(), self.state.model.edges.len()))
287 .color(th.text)
288 .strong(),
289 );
290 ui.separator();
291 ui.label(
292 egui::RichText::new(format!("{} ring(s) · {} badge(s)",
293 self.state.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
294 self.state.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
295 ))
296 .color(th.text_dim),
297 );
298 if ui.button("⊙ fit").clicked() {
299 msgs.push(Msg::Fit);
300 }
301 });
302 ui.separator();
303
304 let (resp, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
305 painter.rect_filled(resp.rect, 4.0, th.bg);
306 if resp.dragged() {
307 let d = resp.drag_delta();
308 msgs.push(Msg::PanBy(d.x, d.y));
309 }
310 if resp.hovered() {
311 let scroll = ui.input(|i| i.smooth_scroll_delta.y);
312 if scroll != 0.0 {
313 msgs.push(Msg::ZoomBy(scroll));
314 }
315 }
316 let origin = resp.rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
317 let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
318
319 if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
321 let hit = self.state.model.nodes.iter().find_map(|nd| {
322 let c = project(nd.pos);
323 let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
324 rect.contains(click).then(|| nd.id.clone())
325 });
326 msgs.push(Msg::Select(hit));
327 }
328
329 let lit = self.lit_set();
330 let highlighting = self.state.selected.is_some();
331 let dim = |c: egui::Color32| c.linear_multiply(0.22);
332
333 let idx: HashMap<&str, &GraphNode> =
334 self.state.model.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
335
336 let draw_edge = |e: &GraphEdge, emphasise: bool| {
338 let (Some(fa), Some(fb)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
339 return;
340 };
341 let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
342 let a = project(fa.pos) + egui::vec2(BOX_W * 0.5 * zoom, 0.0);
343 let b = project(fb.pos) - egui::vec2(BOX_W * 0.5 * zoom, 0.0);
344 let mut color = Self::col(e.color);
345 if !emphasise && highlighting && !on_trace {
346 color = dim(color);
347 }
348 let w = if emphasise { 2.6 } else if on_trace { 2.4 } else { 1.4 };
349 if e.dashed {
350 let n = 16;
352 for i in 0..n {
353 if i % 2 == 0 {
354 let t0 = i as f32 / n as f32;
355 let t1 = (i + 1) as f32 / n as f32;
356 painter.line_segment([a.lerp(b, t0), a.lerp(b, t1)], egui::Stroke::new(w, color));
357 }
358 }
359 } else {
360 painter.line_segment([a, b], egui::Stroke::new(w, color));
361 }
362 let dir = (b - a).normalized();
364 let perp = egui::vec2(-dir.y, dir.x);
365 let head = 6.0 * zoom.clamp(0.6, 1.6);
366 painter.line_segment([b, b - dir * head + perp * head * 0.5], egui::Stroke::new(w, color));
367 painter.line_segment([b, b - dir * head - perp * head * 0.5], egui::Stroke::new(w, color));
368 if let Some(lbl) = &e.label {
369 if zoom > 0.45 && !lbl.is_empty() {
370 painter.text(
371 a.lerp(b, 0.5) - egui::vec2(0.0, 6.0 * zoom),
372 egui::Align2::CENTER_BOTTOM,
373 lbl,
374 egui::FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
375 if emphasise { color } else { th.text_dim },
376 );
377 }
378 }
379 };
380 for e in &self.state.model.edges {
381 draw_edge(e, false);
382 }
383 for e in &self.state.decorations.edges {
384 draw_edge(e, true);
385 }
386
387 for nd in &self.state.model.nodes {
389 let c = project(nd.pos);
390 let on_trace = highlighting && lit.contains(&nd.id);
391 let mut fill = Self::col(nd.fill);
392 let mut stroke = Self::col(nd.stroke);
393 if highlighting && !on_trace {
394 fill = dim(fill);
395 stroke = dim(stroke);
396 }
397 let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
398 painter.rect_filled(rect, 5.0 * zoom, fill);
399
400 let deco = self.state.decorations.nodes.get(&nd.id);
401 let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
402 egui::Stroke::new(3.0, th.accent)
403 } else if let Some(rc) = deco.and_then(|d| d.ring) {
404 let rc = Self::col(rc);
405 egui::Stroke::new(2.4, if highlighting && !on_trace { dim(rc) } else { rc })
406 } else {
407 egui::Stroke::new(1.4, stroke)
408 };
409 painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
410
411 if zoom > 0.45 {
412 painter.text(
413 c,
414 egui::Align2::CENTER_CENTER,
415 &nd.label,
416 egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
417 th.text,
418 );
419 if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
420 let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(th.text);
421 painter.text(
422 rect.right_top() + egui::vec2(-2.0, 1.0),
423 egui::Align2::RIGHT_TOP,
424 badge,
425 egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
426 if highlighting && !on_trace { dim(bc) } else { bc },
427 );
428 }
429 }
430 }
431
432 resp.widget_info(|| {
434 Semantics::image(format!(
435 "decorated graph — {} nodes, {} edges, selected {}",
436 self.state.model.nodes.len(),
437 self.state.model.edges.len(),
438 self.state.selected.as_deref().unwrap_or("none"),
439 ))
440 .widget_info()
441 });
442
443 #[cfg(feature = "testmatrix")]
444 facett_core::testmatrix::emit(
445 "facett-graphview::DecoratedGraphView::view",
446 "ui_render",
447 !self.state.model.nodes.is_empty(),
448 &format!("nodes={} edges={} lit={}", self.state.model.nodes.len(), self.state.model.edges.len(), lit.len()),
449 );
450
451 msgs
452 }
453}
454
455impl facett_core::Elm for DecoratedGraphView {
457 type Model = GraphViewState;
458 type Msg = Msg;
459 type Effect = Effect;
460
461 fn title(&self) -> &str {
462 &self.title
463 }
464 fn state(&self) -> &GraphViewState {
465 &self.state
466 }
467 fn update(&mut self, msg: Msg) -> Vec<Effect> {
468 DecoratedGraphView::update(self, msg)
469 }
470 fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
471 DecoratedGraphView::view(self, ui)
472 }
473}
474
475facett_core::impl_facet_via_elm!(DecoratedGraphView, custom_state_json, {
484 fn state_json(&self) -> serde_json::Value {
485 let lit = self.lit_set();
486 serde_json::json!({
487 "title": self.title,
488 "node_count": self.state.model.nodes.len(),
489 "edge_count": self.state.model.edges.len(),
490 "decoration_edges": self.state.decorations.edges.len(),
491 "rings": self.state.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
492 "badges": self.state.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
493 "selected": self.state.selected,
494 "lit": lit.iter().cloned().collect::<Vec<_>>(),
495 "zoom": self.state.zoom,
496 "nodes": self.state.model.nodes.iter().map(|n| {
497 let d = self.state.decorations.nodes.get(&n.id);
498 serde_json::json!({
499 "id": n.id,
500 "label": n.label,
501 "x": n.pos.x,
502 "y": n.pos.y,
503 "ring": d.and_then(|d| d.ring).is_some(),
504 "badge": d.and_then(|d| d.badge.clone()),
505 })
506 }).collect::<Vec<_>>(),
507 "edges": self.state.model.edges.iter().map(|e| serde_json::json!({
508 "from": e.from, "to": e.to, "dashed": e.dashed,
509 })).collect::<Vec<_>>(),
510 })
511 }
512
513 fn copy(&mut self) -> Option<String> {
514 self.copy_payload().map(|p| p.as_text())
515 }
516
517 fn selection_json(&self) -> serde_json::Value {
518 match &self.state.selected {
519 Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
520 None => serde_json::Value::Null,
521 }
522 }
523
524 fn caps(&self) -> FacetCaps {
527 FacetCaps::NONE.themeable().resizable().selectable().navigable().copyable()
528 }
529
530 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
531 Some(self)
532 }
533});
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use facett_core::Facet;
541
542 #[test]
543 fn typed_copy_is_selected_label_or_the_label_list() {
544 use facett_core::clip::{ClipKind, CopySource};
545 let mut v = DecoratedGraphView::local();
546 let p = v.copy_payload().expect("populated graph copies");
548 assert_eq!(p.kind(), ClipKind::Text);
549 assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
550 v.select(Some("a".into()));
552 let sel = v.copy_payload().unwrap().as_text();
553 assert!(!sel.contains('\n'), "selected copy is one label: {sel}");
554 assert_eq!(sel, v.state().model.nodes.iter().find(|n| n.id == "a").unwrap().label);
555 }
556
557 #[test]
558 fn downstream_is_bfs_closure_from_seed() {
559 let v = DecoratedGraphView::local();
560 let from_a = downstream_of(&v.state().model, "a");
562 assert!(from_a.contains("a") && from_a.contains("b") && from_a.contains("c") && from_a.contains("d"));
563 let from_b = downstream_of(&v.state().model, "b");
564 assert!(from_b.contains("b") && from_b.contains("d"));
565 assert!(!from_b.contains("a"), "BFS is forward-only");
566 assert!(!from_b.contains("c"), "c is not downstream of b");
567 }
568
569 #[test]
573 fn local_view_reports_decorations_and_selection() {
574 let mut v = DecoratedGraphView::local();
575 let j = v.state_json();
576 assert_eq!(<DecoratedGraphView as Facet>::title(&v), "DecoratedGraph");
577 assert_eq!(j["node_count"], 4);
578 assert_eq!(j["edge_count"], 4);
579 assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
580 assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
581 assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");
582 let nodes = j["nodes"].as_array().unwrap();
584 let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
585 assert_eq!(a["ring"], true);
586 let b = nodes.iter().find(|n| n["id"] == "b").unwrap();
587 assert_eq!(b["badge"], "⚠");
588 assert_eq!(j["selected"], serde_json::Value::Null);
590 assert!(j["lit"].as_array().unwrap().is_empty());
591
592 v.select(Some("b".into()));
594 let j2 = v.state_json();
595 assert_eq!(j2["selected"], "b");
596 let lit: BTreeSet<String> =
597 j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
598 assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
599 let sel = v.selection_json();
600 assert_eq!(sel["node"], "b");
601 }
602
603 #[test]
604 fn fit_resets_pan_and_zoom() {
605 let mut v = DecoratedGraphView::local();
606 v.state.pan_x = 20.0;
607 v.state.pan_y = 10.0;
608 v.state.zoom = 2.5;
609 v.fit();
610 assert_eq!((v.state().pan_x, v.state().pan_y), (0.0, 0.0));
611 assert_eq!(v.state().zoom, 1.0);
612 assert_eq!(<DecoratedGraphView as Facet>::title(&DecoratedGraphView::remote()), "DecoratedGraph (remote)");
613 }
614
615 #[test]
618 fn harness_snapshot_drives_camera_and_selection() {
619 use facett_core::harness;
620 let mut v = DecoratedGraphView::local();
621 let snap = harness::snapshot(
623 &mut v,
624 [Msg::PanBy(12.0, -4.0), Msg::ZoomBy(500.0), Msg::Select(Some("b".into()))],
625 );
626 assert_eq!((snap.pan_x, snap.pan_y), (12.0, -4.0), "pan accumulates");
627 assert!(snap.zoom > 1.0 && snap.zoom <= 4.0, "zoom grows but stays clamped: {}", snap.zoom);
628 assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
629 assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
630 let snap = harness::snapshot(&mut v, [Msg::ZoomBy(-100000.0), Msg::Fit]);
632 assert_eq!((snap.pan_x, snap.pan_y, snap.zoom), (0.0, 0.0, 1.0), "Fit re-centers + unit-zooms");
633 }
634
635 #[test]
636 fn drive_reports_no_effects_and_state_round_trips() {
637 use facett_core::harness;
638 let mut v = DecoratedGraphView::local();
639 let effects = harness::drive(&mut v, [Msg::Select(Some("a".into())), Msg::PanBy(3.0, 3.0)]);
641 assert!(effects.is_empty(), "FC-8: the pane emits no Effects");
642 let json = serde_json::to_value(v.state()).unwrap();
645 assert_eq!(json["selected"], "a");
646 let back: GraphViewState = serde_json::from_value(json).unwrap();
647 assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole graph survives)");
648 }
649
650 #[test]
654 fn selection_survives_relayout_on_stable_id() {
655 use facett_core::harness;
656 let mut v = DecoratedGraphView::local();
657 let _ = harness::drive(&mut v, [Msg::Select(Some("b".into()))]);
658 let lit_before = v.lit_set();
659 let mut relaid = v.state().model.clone();
661 for (i, n) in relaid.nodes.iter_mut().enumerate() {
662 n.pos = Pos::new(1000.0 + i as f32 * 37.0, -500.0 - i as f32 * 19.0);
663 }
664 v = v.with_model(relaid);
665 assert_eq!(v.state().selected.as_deref(), Some("b"), "selection keyed on id survives the move");
666 assert_eq!(v.lit_set(), lit_before, "the lit downstream set is identical after re-layout");
667 assert_eq!(v.selection_json()["node"], "b");
668 assert!(v.state().model.nodes.iter().any(|n| n.id == "b"));
670 }
671}