1use facett_core::{Facet, FacetCaps, Theme, theme};
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum StationKind {
29 Start,
31 Emitter,
33 PassThrough,
35 Grpc,
37 Terminus,
39 UiClient,
43 CliClient,
47}
48
49impl StationKind {
50 pub fn as_str(self) -> &'static str {
52 match self {
53 StationKind::Start => "start",
54 StationKind::Emitter => "emitter",
55 StationKind::PassThrough => "passthrough",
56 StationKind::Grpc => "grpc",
57 StationKind::Terminus => "terminus",
58 StationKind::UiClient => "ui_client",
59 StationKind::CliClient => "cli_client",
60 }
61 }
62
63 pub fn parse(s: &str) -> Self {
65 match s {
66 "start" => StationKind::Start,
67 "emitter" => StationKind::Emitter,
68 "grpc" => StationKind::Grpc,
69 "terminus" => StationKind::Terminus,
70 "ui_client" => StationKind::UiClient,
71 "cli_client" => StationKind::CliClient,
72 _ => StationKind::PassThrough,
73 }
74 }
75
76 pub fn marker(self) -> Option<&'static str> {
80 match self {
81 StationKind::UiClient => Some("UI"),
82 StationKind::CliClient => Some("CLI"),
83 _ => None,
84 }
85 }
86
87 pub fn is_client_arm(self) -> bool {
90 matches!(self, StationKind::UiClient | StationKind::CliClient)
91 }
92
93 pub fn glyph(self) -> &'static str {
97 match self {
98 StationKind::Start => "▶",
99 StationKind::Emitter => "●",
100 StationKind::PassThrough => "·",
101 StationKind::Grpc => "◆",
102 StationKind::Terminus => "■",
103 StationKind::UiClient => "▲",
104 StationKind::CliClient => "◇",
105 }
106 }
107
108 pub fn gates(self) -> bool {
114 !matches!(self, StationKind::PassThrough | StationKind::UiClient | StationKind::CliClient)
115 }
116
117 pub fn is_station(self) -> bool {
121 !matches!(self, StationKind::PassThrough)
122 }
123}
124
125#[derive(Clone, Debug, PartialEq)]
129pub struct MetroBranch {
130 pub id: String,
132 pub label: String,
134 pub kind: StationKind,
136 pub lit: bool,
140}
141
142impl MetroBranch {
143 pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
146 Self { id: id.into(), label: label.into(), kind, lit }
147 }
148
149 pub fn marker(&self) -> Option<&'static str> {
152 self.kind.marker()
153 }
154}
155
156#[derive(Clone, Debug, PartialEq)]
158pub struct MetroStation {
159 pub id: String,
161 pub label: String,
163 pub kind: StationKind,
164 pub lit: bool,
167 pub branches: Vec<MetroBranch>,
172}
173
174impl MetroStation {
175 pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
178 Self {
179 id: id.into(),
180 label: label.into(),
181 kind,
182 lit: if kind == StationKind::PassThrough { true } else { lit },
183 branches: Vec::new(),
184 }
185 }
186
187 pub fn with_branches(
190 id: impl Into<String>,
191 label: impl Into<String>,
192 kind: StationKind,
193 lit: bool,
194 branches: Vec<MetroBranch>,
195 ) -> Self {
196 let mut s = Self::new(id, label, kind, lit);
197 s.branches = branches;
198 s
199 }
200
201 pub fn has_branches(&self) -> bool {
203 !self.branches.is_empty()
204 }
205}
206
207#[derive(Clone, Debug, PartialEq)]
209pub struct MetroLine {
210 pub id: String,
212 pub label: String,
214 pub stations: Vec<MetroStation>,
216}
217
218impl MetroLine {
219 pub fn new(id: impl Into<String>, label: impl Into<String>, stations: Vec<MetroStation>) -> Self {
220 Self { id: id.into(), label: label.into(), stations }
221 }
222
223 pub fn is_green(&self) -> bool {
228 self.stations.iter().filter(|s| s.kind.gates()).all(|s| s.lit)
229 }
230
231 pub fn unlit(&self) -> Vec<&MetroStation> {
234 self.stations.iter().filter(|s| s.kind.gates() && !s.lit).collect()
235 }
236
237 pub fn station_count(&self) -> usize {
239 self.stations.iter().filter(|s| s.kind.is_station()).count()
240 }
241}
242
243#[derive(Clone, Debug, Default, PartialEq)]
245pub struct MetroMap {
246 pub lines: Vec<MetroLine>,
247}
248
249impl MetroMap {
250 pub fn new(lines: Vec<MetroLine>) -> Self {
251 Self { lines }
252 }
253
254 pub fn is_green(&self) -> bool {
257 self.lines.iter().all(|l| l.is_green())
258 }
259
260 pub fn green_count(&self) -> usize {
262 self.lines.iter().filter(|l| l.is_green()).count()
263 }
264}
265
266const ROW_H: f32 = 64.0;
270const LINE_LEFT: f32 = 150.0;
271const STOP_GAP: f32 = 96.0;
272const STATION_R: f32 = 9.0;
273const TICK_R: f32 = 3.0;
274const LINE_W: f32 = 6.0;
275
276pub struct MetroView {
280 title: String,
281 map: MetroMap,
282 selected: Option<String>,
284}
285
286impl MetroView {
287 pub fn new(title: impl Into<String>) -> Self {
289 Self { title: title.into(), map: MetroMap::default(), selected: None }
290 }
291
292 pub fn set_map(&mut self, map: MetroMap) {
294 self.map = map;
295 }
296
297 pub fn set_title(&mut self, title: impl Into<String>) {
300 self.title = title.into();
301 }
302
303 pub fn map(&self) -> &MetroMap {
305 &self.map
306 }
307
308 pub fn select(&mut self, id: Option<String>) {
310 self.selected = id;
311 }
312
313 pub fn local() -> Self {
319 let mut v = Self::new("Metro");
320 v.set_map(demo_map());
321 v
322 }
323
324 pub fn remote() -> Self {
328 let mut v = Self::local();
329 v.title = "Metro (remote)".into();
330 v
331 }
332
333 fn stop_color(th: &Theme, line_green: bool, lit: bool) -> egui::Color32 {
336 if !lit {
337 egui::Color32::from_rgb(224, 90, 90)
340 } else if line_green {
341 th.point } else {
343 th.accent }
345 }
346
347 fn line_color(th: &Theme, line_green: bool) -> egui::Color32 {
349 if line_green { th.point } else { th.text_dim }
350 }
351
352 fn arm_color(th: &Theme, kind: StationKind, lit: bool) -> egui::Color32 {
357 let base = match kind {
358 StationKind::CliClient => egui::Color32::from_rgb(214, 160, 70), _ => th.accent, };
361 if lit {
362 base
363 } else {
364 egui::Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), 120)
366 }
367 }
368}
369
370const ARM_RISE: f32 = 30.0;
373const ARM_RUN: f32 = 34.0;
375const ARM_R: f32 = 7.0;
377
378pub fn demo_map() -> MetroMap {
383 let line_a = MetroLine::new(
384 "bench_run→Bench.Submit",
385 "Bench Run",
386 vec![
387 MetroStation::new("ui::bench_button", "Run", StationKind::Start, true),
388 MetroStation::new("bench::collect", "collect", StationKind::Emitter, true),
389 MetroStation::new("bench::normalize", "normalize", StationKind::PassThrough, true),
390 MetroStation::new("bench::submit", "submit", StationKind::Emitter, true),
391 MetroStation::with_branches(
393 "grpc::Bench.Submit",
394 "Bench.Submit",
395 StationKind::Grpc,
396 true,
397 vec![
398 MetroBranch::new("caller::ui::Bench.Submit", "viz", StationKind::UiClient, true),
399 MetroBranch::new("caller::cli::Bench.Submit", "nornir", StationKind::CliClient, true),
400 ],
401 ),
402 MetroStation::new("warehouse::bench_runs", "bench_runs", StationKind::Terminus, true),
403 ],
404 );
405 let line_b = MetroLine::new(
406 "docs_export→Docs.Render",
407 "Docs Export",
408 vec![
409 MetroStation::new("ui::docs_button", "Export", StationKind::Start, true),
410 MetroStation::new("docs::gather", "gather", StationKind::Emitter, true),
411 MetroStation::new("docs::render_svg", "render_svg", StationKind::Emitter, false), MetroStation::with_branches(
414 "grpc::Docs.Render",
415 "Docs.Render",
416 StationKind::Grpc,
417 true,
418 vec![MetroBranch::new("caller::cli::Docs.Render", "nornir-mcp", StationKind::CliClient, true)],
419 ),
420 MetroStation::new("warehouse::doc_exports", "doc_exports", StationKind::Terminus, true),
421 ],
422 );
423 MetroMap::new(vec![line_a, line_b])
424}
425
426impl Facet for MetroView {
427 fn title(&self) -> &str {
428 &self.title
429 }
430
431 fn ui(&mut self, ui: &mut egui::Ui) {
432 let th = theme(ui);
433
434 ui.horizontal_wrapped(|ui| {
436 let total = self.map.lines.len();
437 let green = self.map.green_count();
438 ui.label(egui::RichText::new(format!("{total} line(s)")).color(th.text).strong());
439 ui.separator();
440 ui.label(
441 egui::RichText::new(format!("✓ {green} green"))
442 .color(if green == total && total > 0 { th.point } else { th.text_dim }),
443 );
444 ui.label(
445 egui::RichText::new(format!("✗ {} red", total - green))
446 .color(if green < total { egui::Color32::from_rgb(224, 90, 90) } else { th.text_dim }),
447 );
448 });
449 ui.separator();
450
451 if self.map.lines.is_empty() {
452 ui.label(egui::RichText::new("no lines").color(th.text_dim));
453 #[cfg(feature = "testmatrix")]
454 facett_core::testmatrix::emit(
455 "facett-graphview::MetroView::ui",
456 "ui_render",
457 true,
458 "lines=0 drew=empty_hint",
459 );
460 return;
461 }
462
463 let n = self.map.lines.len();
465 let (rect, _resp) = ui.allocate_exact_size(
466 egui::vec2(ui.available_width().max(640.0), (ROW_H + ARM_RISE) * n as f32 + 16.0),
469 egui::Sense::hover(),
470 );
471 let painter = ui.painter_at(rect);
472
473 for (li, line) in self.map.lines.iter().enumerate() {
474 let green = line.is_green();
475 let y = rect.top() + (ROW_H + ARM_RISE) * li as f32 + ARM_RISE + ROW_H * 0.5;
478 let line_col = MetroView::line_color(&th, green);
479
480 painter.text(
482 egui::pos2(rect.left() + 8.0, y),
483 egui::Align2::LEFT_CENTER,
484 &line.label,
485 egui::FontId::proportional(14.0),
486 if green { th.point } else { th.text },
487 );
488
489 let x0 = rect.left() + LINE_LEFT;
491 let stops = &line.stations;
492 if !stops.is_empty() {
493 let x_last = x0 + STOP_GAP * (stops.len() as f32 - 1.0);
494 painter.line_segment(
495 [egui::pos2(x0, y), egui::pos2(x_last.max(x0), y)],
496 egui::Stroke::new(LINE_W, line_col),
497 );
498 }
499
500 for (si, st) in stops.iter().enumerate() {
502 let x = x0 + STOP_GAP * si as f32;
503 let center = egui::pos2(x, y);
504 if st.kind == StationKind::PassThrough {
505 painter.circle_filled(center, TICK_R, th.text_dim);
507 } else {
508 let fill = MetroView::stop_color(&th, green, st.lit);
509 if st.lit {
510 painter.circle_filled(center, STATION_R, fill);
511 } else {
512 painter.circle_stroke(center, STATION_R, egui::Stroke::new(2.5, fill));
514 }
515 let glyph = st.kind.glyph();
517 if matches!(st.kind, StationKind::Start | StationKind::Grpc | StationKind::Terminus) {
518 painter.text(
519 egui::pos2(x, y - STATION_R - 11.0),
520 egui::Align2::CENTER_CENTER,
521 glyph,
522 egui::FontId::proportional(12.0),
523 th.text,
524 );
525 }
526 }
527 painter.text(
529 egui::pos2(x, y + STATION_R + 9.0),
530 egui::Align2::CENTER_CENTER,
531 &st.label,
532 egui::FontId::proportional(10.0),
533 th.text_dim,
534 );
535
536 if st.has_branches() {
541 let m = st.branches.len();
542 for (bi, br) in st.branches.iter().enumerate() {
543 let frac = if m > 1 { bi as f32 / (m as f32 - 1.0) - 0.5 } else { 0.0 };
545 let ex = x + frac * (ARM_RUN * (m as f32).min(2.0));
546 let ey = y - ARM_RISE;
547 let col = MetroView::arm_color(&th, br.kind, br.lit);
548 painter.line_segment(
550 [center, egui::pos2(ex, ey)],
551 egui::Stroke::new(3.0, col),
552 );
553 let ep = egui::pos2(ex, ey);
555 if br.lit {
556 painter.circle_filled(ep, ARM_R, col);
557 } else {
558 painter.circle_stroke(ep, ARM_R, egui::Stroke::new(2.0, col));
559 }
560 painter.text(
562 egui::pos2(ex, ey - ARM_R - 8.0),
563 egui::Align2::CENTER_CENTER,
564 format!("{} {}", br.kind.glyph(), br.marker().unwrap_or("")),
565 egui::FontId::proportional(10.0),
566 col,
567 );
568 }
569 }
570 }
571 }
572
573 #[cfg(feature = "testmatrix")]
574 facett_core::testmatrix::emit(
575 "facett-graphview::MetroView::ui",
576 "ui_render",
577 !self.map.lines.is_empty(),
578 &format!("lines={} green={}", self.map.lines.len(), self.map.green_count()),
579 );
580 }
581
582 fn state_json(&self) -> serde_json::Value {
583 serde_json::json!({
584 "title": self.title,
585 "lines": self.map.lines.len(),
586 "green_lines": self.map.green_count(),
587 "green": self.map.is_green(),
588 "selected": self.selected,
589 "line": self.map.lines.iter().map(|l| serde_json::json!({
590 "id": l.id,
591 "label": l.label,
592 "green": l.is_green(),
593 "station_count": l.station_count(),
594 "unlit": l.unlit().iter().map(|s| s.label.clone()).collect::<Vec<_>>(),
595 "stations": l.stations.iter().map(|s| serde_json::json!({
596 "id": s.id,
597 "label": s.label,
598 "kind": s.kind.as_str(),
599 "lit": s.lit,
600 "branches": s.branches.iter().map(|b| serde_json::json!({
603 "id": b.id,
604 "label": b.label,
605 "kind": b.kind.as_str(),
606 "marker": b.marker(),
607 "lit": b.lit,
608 })).collect::<Vec<_>>(),
609 })).collect::<Vec<_>>(),
610 })).collect::<Vec<_>>(),
611 })
612 }
613
614 fn selection_json(&self) -> serde_json::Value {
615 match &self.selected {
616 Some(id) => serde_json::json!({ "line": id }),
617 None => serde_json::Value::Null,
618 }
619 }
620
621 fn caps(&self) -> FacetCaps {
624 FacetCaps::NONE.themeable().resizable().selectable()
625 }
626
627 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
628 Some(self)
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn green_only_when_every_gating_station_lit() {
638 let line = MetroLine::new(
639 "l",
640 "L",
641 vec![
642 MetroStation::new("s", "start", StationKind::Start, true),
643 MetroStation::new("e", "emit", StationKind::Emitter, true),
644 MetroStation::new("p", "pass", StationKind::PassThrough, false), MetroStation::new("g", "grpc", StationKind::Grpc, true),
646 MetroStation::new("t", "wh", StationKind::Terminus, true),
647 ],
648 );
649 assert!(line.is_green(), "all gating stops lit + a pass-through doesn't gate");
650 assert!(line.unlit().is_empty());
651 }
652
653 #[test]
654 fn one_unlit_emitter_makes_line_red() {
655 let line = MetroLine::new(
656 "l",
657 "L",
658 vec![
659 MetroStation::new("s", "start", StationKind::Start, true),
660 MetroStation::new("e", "emit", StationKind::Emitter, false),
661 ],
662 );
663 assert!(!line.is_green());
664 assert_eq!(line.unlit().len(), 1);
665 assert_eq!(line.unlit()[0].label, "emit");
666 }
667
668 #[test]
669 fn passthrough_constructed_lit_and_never_gates() {
670 let p = MetroStation::new("p", "p", StationKind::PassThrough, false);
671 assert!(p.lit, "pass-through is forced lit (nothing to light)");
672 assert!(!p.kind.gates());
673 assert!(!p.kind.is_station());
674 }
675
676 #[test]
677 fn kind_parse_roundtrip() {
678 for k in [
679 StationKind::UiClient,
680 StationKind::CliClient,
681 StationKind::Start,
682 StationKind::Emitter,
683 StationKind::PassThrough,
684 StationKind::Grpc,
685 StationKind::Terminus,
686 ] {
687 assert_eq!(StationKind::parse(k.as_str()), k);
688 }
689 assert_eq!(StationKind::parse("bogus"), StationKind::PassThrough);
690 }
691
692 #[test]
693 fn map_green_iff_all_lines_green() {
694 let m = demo_map();
695 assert_eq!(m.lines.len(), 2);
696 assert!(!m.is_green(), "line B has an unlit emitter");
697 assert_eq!(m.green_count(), 1);
698 }
699
700 #[test]
701 fn local_builds_two_line_demo() {
702 let v = MetroView::local();
703 assert_eq!(v.map().lines.len(), 2);
704 assert!(v.map().lines[0].is_green());
705 assert!(!v.map().lines[1].is_green());
706 }
707
708 #[test]
713 fn set_title_rekeys_view_and_keeps_the_map() {
714 let mut v = MetroView::local();
715 v.set_title("metro");
716 assert_eq!(Facet::title(&v), "metro", "title() reflects the new key");
717 let sj = v.state_json();
718 assert_eq!(sj["title"], "metro", "state_json carries the new title");
719 assert_eq!(sj["lines"].as_u64(), Some(2), "the demo map survived the re-title");
720 assert_eq!(sj["green_lines"].as_u64(), Some(1), "one green / one red line still");
721 }
722
723 #[test]
729 fn caller_arms_never_gate_the_trunk() {
730 assert!(!StationKind::UiClient.gates());
731 assert!(!StationKind::CliClient.gates());
732 assert!(StationKind::UiClient.is_client_arm() && StationKind::CliClient.is_client_arm());
733
734 let line = MetroLine::new(
735 "l",
736 "L",
737 vec![
738 MetroStation::new("s", "start", StationKind::Start, true),
739 MetroStation::with_branches(
740 "g",
741 "grpc",
742 StationKind::Grpc,
743 true,
744 vec![
745 MetroBranch::new("ui", "viz", StationKind::UiClient, false), MetroBranch::new("cli", "nornir", StationKind::CliClient, true),
747 ],
748 ),
749 MetroStation::new("t", "wh", StationKind::Terminus, true),
750 ],
751 );
752 assert!(line.is_green(), "an unlit caller arm does not gate the green trunk");
753 assert!(line.unlit().is_empty(), "arms are not counted as unlit gating stops");
754 }
755
756 #[test]
758 fn client_kinds_carry_the_right_marker() {
759 assert_eq!(StationKind::UiClient.marker(), Some("UI"));
760 assert_eq!(StationKind::CliClient.marker(), Some("CLI"));
761 assert_eq!(StationKind::Grpc.marker(), None);
762 let b = MetroBranch::new("x", "viz", StationKind::UiClient, true);
763 assert_eq!(b.marker(), Some("UI"));
764 }
765
766 #[test]
770 fn state_json_carries_the_branch_topology_and_markers() {
771 let v = MetroView::local(); let sj = v.state_json();
773 let lines = sj["line"].as_array().unwrap();
774
775 let line_a = &lines[0];
777 let grpc_a = line_a["stations"]
778 .as_array()
779 .unwrap()
780 .iter()
781 .find(|s| s["kind"] == "grpc")
782 .expect("line A has a grpc station");
783 let arms_a = grpc_a["branches"].as_array().unwrap();
784 assert_eq!(arms_a.len(), 2, "the verb called by BOTH renders two arms");
785 let markers_a: Vec<&str> = arms_a.iter().map(|b| b["marker"].as_str().unwrap()).collect();
786 assert!(markers_a.contains(&"UI") && markers_a.contains(&"CLI"), "UI+CLI markers: {markers_a:?}");
787 assert!(arms_a.iter().all(|b| b["lit"].as_bool().unwrap()), "both arms lit");
788 assert_eq!(arms_a.iter().find(|b| b["marker"] == "UI").unwrap()["kind"], "ui_client");
789 assert_eq!(arms_a.iter().find(|b| b["marker"] == "CLI").unwrap()["kind"], "cli_client");
790
791 let line_b = &lines[1];
793 let grpc_b = line_b["stations"]
794 .as_array()
795 .unwrap()
796 .iter()
797 .find(|s| s["kind"] == "grpc")
798 .expect("line B has a grpc station");
799 let arms_b = grpc_b["branches"].as_array().unwrap();
800 assert_eq!(arms_b.len(), 1, "the CLI-only verb renders a single arm");
801 assert_eq!(arms_b[0]["marker"], "CLI");
802 assert_eq!(arms_b[0]["kind"], "cli_client");
803
804 let start_a = line_a["stations"].as_array().unwrap().iter().find(|s| s["kind"] == "start").unwrap();
806 assert_eq!(start_a["branches"].as_array().unwrap().len(), 0, "linear stop has no forks");
807 }
808
809 #[test]
812 fn empty_map_is_the_empty_state() {
813 let v = MetroView::new("Metro");
814 let sj = v.state_json();
815 assert_eq!(sj["lines"].as_u64(), Some(0), "no lines on an empty map");
816 assert_eq!(sj["line"].as_array().unwrap().len(), 0);
817 assert!(v.map().lines.is_empty());
818 }
819}