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 MetroView {
427 pub fn copy_text(&self) -> Option<String> {
430 if self.map.lines.is_empty() {
431 return None;
432 }
433 if let Some(id) = self.selected.as_deref() {
434 if let Some(l) = self.map.lines.iter().find(|l| l.id == id) {
435 return Some(l.label.clone());
436 }
437 }
438 Some(self.map.lines.iter().map(|l| l.label.clone()).collect::<Vec<_>>().join("\n"))
439 }
440}
441
442impl facett_core::clip::CopySource for MetroView {
444 fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
445 use facett_core::clip::ClipKind;
446 &[ClipKind::Text]
447 }
448 fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
449 self.copy_text().map(facett_core::clip::ClipPayload::Text)
450 }
451}
452
453impl Facet for MetroView {
454 fn title(&self) -> &str {
455 &self.title
456 }
457
458 fn copy(&mut self) -> Option<String> {
459 use facett_core::clip::CopySource as _;
460 self.copy_payload().map(|p| p.as_text())
461 }
462
463 fn ui(&mut self, ui: &mut egui::Ui) {
464 let th = theme(ui);
465
466 ui.horizontal_wrapped(|ui| {
468 let total = self.map.lines.len();
469 let green = self.map.green_count();
470 ui.label(egui::RichText::new(format!("{total} line(s)")).color(th.text).strong());
471 ui.separator();
472 ui.label(
473 egui::RichText::new(format!("✓ {green} green"))
474 .color(if green == total && total > 0 { th.point } else { th.text_dim }),
475 );
476 ui.label(
477 egui::RichText::new(format!("✗ {} red", total - green))
478 .color(if green < total { egui::Color32::from_rgb(224, 90, 90) } else { th.text_dim }),
479 );
480 });
481 ui.separator();
482
483 if self.map.lines.is_empty() {
484 ui.label(egui::RichText::new("no lines").color(th.text_dim));
485 #[cfg(feature = "testmatrix")]
486 facett_core::testmatrix::emit(
487 "facett-graphview::MetroView::ui",
488 "ui_render",
489 true,
490 "lines=0 drew=empty_hint",
491 );
492 return;
493 }
494
495 let n = self.map.lines.len();
497 let (rect, _resp) = ui.allocate_exact_size(
498 egui::vec2(ui.available_width().max(640.0), (ROW_H + ARM_RISE) * n as f32 + 16.0),
501 egui::Sense::hover(),
502 );
503 let painter = ui.painter_at(rect);
504
505 for (li, line) in self.map.lines.iter().enumerate() {
506 let green = line.is_green();
507 let y = rect.top() + (ROW_H + ARM_RISE) * li as f32 + ARM_RISE + ROW_H * 0.5;
510 let line_col = MetroView::line_color(&th, green);
511
512 painter.text(
514 egui::pos2(rect.left() + 8.0, y),
515 egui::Align2::LEFT_CENTER,
516 &line.label,
517 egui::FontId::proportional(14.0),
518 if green { th.point } else { th.text },
519 );
520
521 let x0 = rect.left() + LINE_LEFT;
523 let stops = &line.stations;
524 if !stops.is_empty() {
525 let x_last = x0 + STOP_GAP * (stops.len() as f32 - 1.0);
526 painter.line_segment(
527 [egui::pos2(x0, y), egui::pos2(x_last.max(x0), y)],
528 egui::Stroke::new(LINE_W, line_col),
529 );
530 }
531
532 for (si, st) in stops.iter().enumerate() {
534 let x = x0 + STOP_GAP * si as f32;
535 let center = egui::pos2(x, y);
536 if st.kind == StationKind::PassThrough {
537 painter.circle_filled(center, TICK_R, th.text_dim);
539 } else {
540 let fill = MetroView::stop_color(&th, green, st.lit);
541 if st.lit {
542 painter.circle_filled(center, STATION_R, fill);
543 } else {
544 painter.circle_stroke(center, STATION_R, egui::Stroke::new(2.5, fill));
546 }
547 let glyph = st.kind.glyph();
549 if matches!(st.kind, StationKind::Start | StationKind::Grpc | StationKind::Terminus) {
550 painter.text(
551 egui::pos2(x, y - STATION_R - 11.0),
552 egui::Align2::CENTER_CENTER,
553 glyph,
554 egui::FontId::proportional(12.0),
555 th.text,
556 );
557 }
558 }
559 painter.text(
561 egui::pos2(x, y + STATION_R + 9.0),
562 egui::Align2::CENTER_CENTER,
563 &st.label,
564 egui::FontId::proportional(10.0),
565 th.text_dim,
566 );
567
568 if st.has_branches() {
573 let m = st.branches.len();
574 for (bi, br) in st.branches.iter().enumerate() {
575 let frac = if m > 1 { bi as f32 / (m as f32 - 1.0) - 0.5 } else { 0.0 };
577 let ex = x + frac * (ARM_RUN * (m as f32).min(2.0));
578 let ey = y - ARM_RISE;
579 let col = MetroView::arm_color(&th, br.kind, br.lit);
580 painter.line_segment(
582 [center, egui::pos2(ex, ey)],
583 egui::Stroke::new(3.0, col),
584 );
585 let ep = egui::pos2(ex, ey);
587 if br.lit {
588 painter.circle_filled(ep, ARM_R, col);
589 } else {
590 painter.circle_stroke(ep, ARM_R, egui::Stroke::new(2.0, col));
591 }
592 painter.text(
594 egui::pos2(ex, ey - ARM_R - 8.0),
595 egui::Align2::CENTER_CENTER,
596 format!("{} {}", br.kind.glyph(), br.marker().unwrap_or("")),
597 egui::FontId::proportional(10.0),
598 col,
599 );
600 }
601 }
602 }
603 }
604
605 #[cfg(feature = "testmatrix")]
606 facett_core::testmatrix::emit(
607 "facett-graphview::MetroView::ui",
608 "ui_render",
609 !self.map.lines.is_empty(),
610 &format!("lines={} green={}", self.map.lines.len(), self.map.green_count()),
611 );
612 }
613
614 fn state_json(&self) -> serde_json::Value {
615 serde_json::json!({
616 "title": self.title,
617 "lines": self.map.lines.len(),
618 "green_lines": self.map.green_count(),
619 "green": self.map.is_green(),
620 "selected": self.selected,
621 "line": self.map.lines.iter().map(|l| serde_json::json!({
622 "id": l.id,
623 "label": l.label,
624 "green": l.is_green(),
625 "station_count": l.station_count(),
626 "unlit": l.unlit().iter().map(|s| s.label.clone()).collect::<Vec<_>>(),
627 "stations": l.stations.iter().map(|s| serde_json::json!({
628 "id": s.id,
629 "label": s.label,
630 "kind": s.kind.as_str(),
631 "lit": s.lit,
632 "branches": s.branches.iter().map(|b| serde_json::json!({
635 "id": b.id,
636 "label": b.label,
637 "kind": b.kind.as_str(),
638 "marker": b.marker(),
639 "lit": b.lit,
640 })).collect::<Vec<_>>(),
641 })).collect::<Vec<_>>(),
642 })).collect::<Vec<_>>(),
643 })
644 }
645
646 fn selection_json(&self) -> serde_json::Value {
647 match &self.selected {
648 Some(id) => serde_json::json!({ "line": id }),
649 None => serde_json::Value::Null,
650 }
651 }
652
653 fn caps(&self) -> FacetCaps {
656 FacetCaps::NONE.themeable().resizable().selectable().copyable()
657 }
658
659 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
660 Some(self)
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn typed_copy_is_selected_line_or_the_line_list() {
670 use facett_core::clip::{ClipKind, CopySource};
671 let mut v = MetroView::new("metro");
672 v.set_map(demo_map());
673 let p = v.copy_payload().expect("populated map copies");
674 assert_eq!(p.kind(), ClipKind::Text);
675 assert_eq!(p.as_text(), "Bench Run\nDocs Export");
676 v.select(Some("bench_run\u{2192}Bench.Submit".into()));
678 assert_eq!(v.copy_payload().unwrap().as_text(), "Bench Run");
679 }
680
681 #[test]
682 fn green_only_when_every_gating_station_lit() {
683 let line = MetroLine::new(
684 "l",
685 "L",
686 vec![
687 MetroStation::new("s", "start", StationKind::Start, true),
688 MetroStation::new("e", "emit", StationKind::Emitter, true),
689 MetroStation::new("p", "pass", StationKind::PassThrough, false), MetroStation::new("g", "grpc", StationKind::Grpc, true),
691 MetroStation::new("t", "wh", StationKind::Terminus, true),
692 ],
693 );
694 assert!(line.is_green(), "all gating stops lit + a pass-through doesn't gate");
695 assert!(line.unlit().is_empty());
696 }
697
698 #[test]
699 fn one_unlit_emitter_makes_line_red() {
700 let line = MetroLine::new(
701 "l",
702 "L",
703 vec![
704 MetroStation::new("s", "start", StationKind::Start, true),
705 MetroStation::new("e", "emit", StationKind::Emitter, false),
706 ],
707 );
708 assert!(!line.is_green());
709 assert_eq!(line.unlit().len(), 1);
710 assert_eq!(line.unlit()[0].label, "emit");
711 }
712
713 #[test]
714 fn passthrough_constructed_lit_and_never_gates() {
715 let p = MetroStation::new("p", "p", StationKind::PassThrough, false);
716 assert!(p.lit, "pass-through is forced lit (nothing to light)");
717 assert!(!p.kind.gates());
718 assert!(!p.kind.is_station());
719 }
720
721 #[test]
722 fn kind_parse_roundtrip() {
723 for k in [
724 StationKind::UiClient,
725 StationKind::CliClient,
726 StationKind::Start,
727 StationKind::Emitter,
728 StationKind::PassThrough,
729 StationKind::Grpc,
730 StationKind::Terminus,
731 ] {
732 assert_eq!(StationKind::parse(k.as_str()), k);
733 }
734 assert_eq!(StationKind::parse("bogus"), StationKind::PassThrough);
735 }
736
737 #[test]
738 fn map_green_iff_all_lines_green() {
739 let m = demo_map();
740 assert_eq!(m.lines.len(), 2);
741 assert!(!m.is_green(), "line B has an unlit emitter");
742 assert_eq!(m.green_count(), 1);
743 }
744
745 #[test]
746 fn local_builds_two_line_demo() {
747 let v = MetroView::local();
748 assert_eq!(v.map().lines.len(), 2);
749 assert!(v.map().lines[0].is_green());
750 assert!(!v.map().lines[1].is_green());
751 }
752
753 #[test]
758 fn set_title_rekeys_view_and_keeps_the_map() {
759 let mut v = MetroView::local();
760 v.set_title("metro");
761 assert_eq!(Facet::title(&v), "metro", "title() reflects the new key");
762 let sj = v.state_json();
763 assert_eq!(sj["title"], "metro", "state_json carries the new title");
764 assert_eq!(sj["lines"].as_u64(), Some(2), "the demo map survived the re-title");
765 assert_eq!(sj["green_lines"].as_u64(), Some(1), "one green / one red line still");
766 }
767
768 #[test]
774 fn caller_arms_never_gate_the_trunk() {
775 assert!(!StationKind::UiClient.gates());
776 assert!(!StationKind::CliClient.gates());
777 assert!(StationKind::UiClient.is_client_arm() && StationKind::CliClient.is_client_arm());
778
779 let line = MetroLine::new(
780 "l",
781 "L",
782 vec![
783 MetroStation::new("s", "start", StationKind::Start, true),
784 MetroStation::with_branches(
785 "g",
786 "grpc",
787 StationKind::Grpc,
788 true,
789 vec![
790 MetroBranch::new("ui", "viz", StationKind::UiClient, false), MetroBranch::new("cli", "nornir", StationKind::CliClient, true),
792 ],
793 ),
794 MetroStation::new("t", "wh", StationKind::Terminus, true),
795 ],
796 );
797 assert!(line.is_green(), "an unlit caller arm does not gate the green trunk");
798 assert!(line.unlit().is_empty(), "arms are not counted as unlit gating stops");
799 }
800
801 #[test]
803 fn client_kinds_carry_the_right_marker() {
804 assert_eq!(StationKind::UiClient.marker(), Some("UI"));
805 assert_eq!(StationKind::CliClient.marker(), Some("CLI"));
806 assert_eq!(StationKind::Grpc.marker(), None);
807 let b = MetroBranch::new("x", "viz", StationKind::UiClient, true);
808 assert_eq!(b.marker(), Some("UI"));
809 }
810
811 #[test]
815 fn state_json_carries_the_branch_topology_and_markers() {
816 let v = MetroView::local(); let sj = v.state_json();
818 let lines = sj["line"].as_array().unwrap();
819
820 let line_a = &lines[0];
822 let grpc_a = line_a["stations"]
823 .as_array()
824 .unwrap()
825 .iter()
826 .find(|s| s["kind"] == "grpc")
827 .expect("line A has a grpc station");
828 let arms_a = grpc_a["branches"].as_array().unwrap();
829 assert_eq!(arms_a.len(), 2, "the verb called by BOTH renders two arms");
830 let markers_a: Vec<&str> = arms_a.iter().map(|b| b["marker"].as_str().unwrap()).collect();
831 assert!(markers_a.contains(&"UI") && markers_a.contains(&"CLI"), "UI+CLI markers: {markers_a:?}");
832 assert!(arms_a.iter().all(|b| b["lit"].as_bool().unwrap()), "both arms lit");
833 assert_eq!(arms_a.iter().find(|b| b["marker"] == "UI").unwrap()["kind"], "ui_client");
834 assert_eq!(arms_a.iter().find(|b| b["marker"] == "CLI").unwrap()["kind"], "cli_client");
835
836 let line_b = &lines[1];
838 let grpc_b = line_b["stations"]
839 .as_array()
840 .unwrap()
841 .iter()
842 .find(|s| s["kind"] == "grpc")
843 .expect("line B has a grpc station");
844 let arms_b = grpc_b["branches"].as_array().unwrap();
845 assert_eq!(arms_b.len(), 1, "the CLI-only verb renders a single arm");
846 assert_eq!(arms_b[0]["marker"], "CLI");
847 assert_eq!(arms_b[0]["kind"], "cli_client");
848
849 let start_a = line_a["stations"].as_array().unwrap().iter().find(|s| s["kind"] == "start").unwrap();
851 assert_eq!(start_a["branches"].as_array().unwrap().len(), 0, "linear stop has no forks");
852 }
853
854 #[test]
857 fn empty_map_is_the_empty_state() {
858 let v = MetroView::new("Metro");
859 let sj = v.state_json();
860 assert_eq!(sj["lines"].as_u64(), Some(0), "no lines on an empty map");
861 assert_eq!(sj["line"].as_array().unwrap().len(), 0);
862 assert!(v.map().lines.is_empty());
863 }
864}