1use super::color::CellColor;
12use super::node_content::NodeKindTag;
13use super::plan::{LabelPlan, RenderPlan};
14use super::scene::{Scene, ViewRef};
15use super::style::{LabelPosition, LineWeight, MarkerShape, SubgraphBorder};
16use super::view::{LayoutView, PathRef};
17use crate::ir::FlowAxis;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum NodeKind {
29 Real {
31 origin: NodeOrigin,
33 },
34 Dummy,
39}
40
41#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum NodeOrigin {
45 Declared,
47 EdgeInferred,
49}
50
51#[non_exhaustive]
53#[derive(Debug, Clone, Copy)]
54pub struct NodeView<'s> {
55 pub id: Option<usize>,
59 pub dummy_of: Option<(usize, usize)>,
65 pub x: usize,
67 pub y: usize,
69 pub width: usize,
71 pub height: usize,
73 pub kind: NodeKind,
75 pub label: &'s str,
77 pub content: NodeKindTag,
79 pub payload: Option<&'s str>,
83}
84
85#[non_exhaustive]
88#[derive(Debug, Clone, Copy)]
89pub struct EdgeView<'s> {
90 pub scene_index: usize,
94 pub input_index: usize,
99 pub from_id: usize,
101 pub to_id: usize,
103 pub directed: bool,
105 pub reversed: bool,
107 pub color: CellColor,
109 pub weight: LineWeight,
111 pub marker_source: MarkerShape,
116 pub marker_target: MarkerShape,
118 pub from: (usize, usize),
120 pub to: (usize, usize),
122 pub path: EdgePathView<'s>,
124 pub flow_axis: FlowAxis,
126 pub from_port: crate::PortAttachment,
129 pub to_port: crate::PortAttachment,
131 pub label: Option<LabelView<'s>>,
134}
135
136#[non_exhaustive]
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum EdgePathView<'s> {
142 Direct,
144 Corner {
146 bend_at: usize,
148 },
149 SideChannel {
151 channel_at: usize,
153 span_start: usize,
155 span_end: usize,
157 },
158 MultiSegment {
161 waypoints: &'s [(usize, usize)],
163 start_offset: usize,
165 },
166 #[cfg(feature = "ports")]
167 Orthogonal {
171 bends: &'s [(usize, usize)],
173 },
174 SelfLoop {
176 at: (usize, usize),
178 },
179}
180
181#[non_exhaustive]
183#[derive(Debug, Clone, Copy)]
184pub struct LabelView<'s> {
185 pub text: &'s str,
187 pub color: CellColor,
189 pub slot: LabelSlot,
191}
192
193#[non_exhaustive]
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum LabelSlot {
197 Inline {
200 x: usize,
202 y: usize,
204 len: usize,
206 },
207 Legend,
210 Omitted,
213}
214
215#[non_exhaustive]
217#[derive(Debug, Clone, Copy)]
218pub struct SubgraphView<'s> {
219 pub id: usize,
221 pub parent: Option<usize>,
223 pub label: &'s str,
225 pub x: usize,
227 pub y: usize,
229 pub width: usize,
231 pub height: usize,
233 pub border: SubgraphBorder,
235 pub color: CellColor,
237 pub label_position: LabelPosition,
239}
240
241macro_rules! with_view {
243 ($scene:expr, $v:ident => $e:expr) => {
244 match *$scene.view() {
245 #[cfg(feature = "alloc")]
246 ViewRef::Heap($v) => $e,
247 ViewRef::Arena($v) => $e,
248 }
249 };
250}
251
252impl Scene<'_, '_> {
253 pub fn nodes(&self) -> impl Iterator<Item = NodeView<'_>> + '_ {
257 let count = with_view!(self, v => LayoutView::node_count(v));
258 let show_dummies = self.plan().show_dummy_nodes();
259 (0..count)
260 .map(move |i| self.node_view_at(i))
261 .filter(move |n| show_dummies || !matches!(n.kind, NodeKind::Dummy))
262 }
263
264 pub fn node(&self, id: usize) -> Option<NodeView<'_>> {
267 let count = with_view!(self, v => LayoutView::node_count(v));
268 (0..count)
269 .map(|i| self.node_view_at(i))
270 .find(|n| n.id == Some(id))
271 }
272
273 pub fn edges(&self) -> impl Iterator<Item = EdgeView<'_>> + '_ {
277 (0..self.scene_edge_count()).map(move |i| self.edge_view_at(i))
278 }
279
280 pub fn edge(&self, scene_index: usize) -> Option<EdgeView<'_>> {
282 (scene_index < self.scene_edge_count()).then(|| self.edge_view_at(scene_index))
283 }
284
285 fn scene_edge_count(&self) -> usize {
286 with_view!(self, v => LayoutView::edge_count(v) + LayoutView::self_loop_count(v))
287 }
288
289 pub fn subgraphs(&self) -> impl Iterator<Item = SubgraphView<'_>> + '_ {
291 let count = with_view!(self, v => LayoutView::subgraph_count(v));
292 (0..count).map(move |i| self.subgraph_view_at(i))
293 }
294
295 pub fn legend(&self) -> impl Iterator<Item = EdgeView<'_>> + '_ {
301 self.legend_entries()
302 .iter()
303 .map(move |&i| self.edge_view_at(i))
304 }
305
306 fn node_view_at(&self, index: usize) -> NodeView<'_> {
307 with_view!(self, v => {
308 let n = LayoutView::node(v, index);
309 let (painter, payload) = LayoutView::node_custom(v, index);
310 let kind = match n.kind {
311 crate::ir::NodeKind::Explicit => NodeKind::Real {
312 origin: NodeOrigin::Declared,
313 },
314 crate::ir::NodeKind::Implicit => NodeKind::Real {
315 origin: NodeOrigin::EdgeInferred,
316 },
317 crate::ir::NodeKind::Dummy => NodeKind::Dummy,
318 };
319 let dummy = matches!(kind, NodeKind::Dummy);
320 NodeView {
321 id: (!dummy).then_some(n.id),
322 dummy_of: if dummy {
323 Some((n.edge_index.unwrap_or(usize::MAX), n.level))
324 } else {
325 None
326 },
327 x: n.x,
328 y: n.y,
329 width: n.width,
330 height: n.height,
331 kind,
332 label: n.label,
333 content: NodeKindTag::from_u8(n.content_tag),
334 payload: if painter.is_none() && payload.is_empty() {
338 None
339 } else {
340 Some(payload)
341 },
342 }
343 })
344 }
345
346 fn edge_view_at(&self, index: usize) -> EdgeView<'_> {
347 let plan = self.plan();
348 let routed = with_view!(self, v => LayoutView::edge_count(v));
349 if index >= routed {
350 return self.self_loop_view_at(index, index - routed);
351 }
352 with_view!(self, v => {
353 let e = LayoutView::edge(v, index);
354 let ep = plan.edge_plan(index);
355 EdgeView {
356 scene_index: index,
357 input_index: e.edge_index,
358 from_id: e.from_id,
359 to_id: e.to_id,
360 directed: e.directed,
361 reversed: e.reversed,
362 color: ep.color,
363 weight: ep.weight,
364 marker_source: ep.marker_start,
365 marker_target: ep.marker_end,
366 from: (e.from_x, e.from_y),
367 to: (e.to_x, e.to_y),
368 path: match e.path {
369 PathRef::Direct | PathRef::Spline { .. } => EdgePathView::Direct,
374 PathRef::Corner { bend_at } => EdgePathView::Corner { bend_at },
375 PathRef::SideChannel {
376 channel_at,
377 span_start,
378 span_end,
379 } => EdgePathView::SideChannel {
380 channel_at,
381 span_start,
382 span_end,
383 },
384 PathRef::MultiSegment {
385 waypoints,
386 start_offset,
387 } => EdgePathView::MultiSegment {
388 waypoints,
389 start_offset,
390 },
391 #[cfg(feature = "ports")]
392 PathRef::Orthogonal { bends } => EdgePathView::Orthogonal { bends },
393 },
394 flow_axis: e.flow_axis,
395 from_port: e.from_port,
396 to_port: e.to_port,
397 label: e.label.map(|text| LabelView {
398 text,
399 color: ep.label_color,
400 slot: label_slot(plan, index),
401 }),
402 }
403 })
404 }
405
406 fn self_loop_view_at(&self, scene_index: usize, j: usize) -> EdgeView<'_> {
411 fn loop_marker_side(flow_axis: FlowAxis) -> crate::PhysicalSide {
415 match flow_axis {
416 FlowAxis::Y => crate::PhysicalSide::East,
417 FlowAxis::X => crate::PhysicalSide::South,
418 }
419 }
420 let plan = self.plan();
421 with_view!(self, v => {
422 let r = LayoutView::self_loop(v, j);
423 let at = if r.node_index < LayoutView::node_count(v) {
425 LayoutView::node(v, r.node_index).self_loop_at
426 } else {
427 None
428 }
429 .unwrap_or((0, 0));
430 let ep = plan.scene_edge_plan(scene_index);
431 let flow_axis = flow_axis_of(LayoutView::direction(v));
432 EdgeView {
433 scene_index,
434 input_index: r.input_index,
435 from_id: r.node_id,
436 to_id: r.node_id,
437 directed: true,
438 reversed: false,
439 color: ep.color,
440 weight: ep.weight,
441 marker_source: ep.marker_start,
442 marker_target: ep.marker_end,
443 from: at,
444 to: at,
445 path: EdgePathView::SelfLoop { at },
446 flow_axis,
447 from_port: crate::PortAttachment::auto(loop_marker_side(flow_axis)),
451 to_port: crate::PortAttachment::auto(loop_marker_side(flow_axis)),
452 label: r.label.map(|text| LabelView {
453 text,
454 color: ep.label_color,
455 slot: if plan.legend_entries().binary_search(&scene_index).is_ok() {
456 LabelSlot::Legend
457 } else {
458 LabelSlot::Omitted
459 },
460 }),
461 }
462 })
463 }
464
465 fn subgraph_view_at(&self, index: usize) -> SubgraphView<'_> {
466 let plan = self.plan();
467 with_view!(self, v => {
468 let sg = LayoutView::subgraph(v, index);
469 let sp = plan.subgraph_plan(index);
470 SubgraphView {
471 id: sg.id,
472 parent: sg.parent,
473 label: sg.label,
474 x: sg.x,
475 y: sg.y,
476 width: sg.width,
477 height: sg.height,
478 border: sp.border,
479 color: sp.color,
480 label_position: sp.label_pos,
481 }
482 })
483 }
484}
485
486fn flow_axis_of(direction: crate::graph::Direction) -> FlowAxis {
490 #[cfg(feature = "layout-horizontal")]
491 if matches!(
492 direction,
493 crate::graph::Direction::LeftRight | crate::graph::Direction::RightLeft
494 ) {
495 return FlowAxis::X;
496 }
497 let _ = direction;
498 FlowAxis::Y
499}
500
501fn label_slot(plan: &RenderPlan<'_>, index: usize) -> LabelSlot {
504 let labels = plan.labels();
505 let Ok(pos) = labels.binary_search_by_key(&index, |l: &LabelPlan| l.edge_index) else {
506 return LabelSlot::Omitted;
509 };
510 let label = &labels[pos];
511 if label.paints_under(plan.label_placement()) {
512 LabelSlot::Inline {
513 x: label.x,
514 y: label.y,
515 len: label.len,
516 }
517 } else if plan.legend_entries().binary_search(&index).is_ok() {
518 LabelSlot::Legend
519 } else {
520 LabelSlot::Omitted
521 }
522}
523
524#[cfg(all(test, feature = "std", feature = "layout-vertical"))]
525mod tests {
526 use super::super::config::{LabelOverflow, LabelPolicy, PlanOptions};
527 use super::super::scene::ScenePlanner;
528 use super::super::test_alloc::allocations_on_this_thread;
529 use super::*;
530 use crate::graph::Graph;
531 use crate::graph::arena::Arena;
532 use crate::render::engine::{BoxedNode, CustomNode};
533
534 fn corpus_graph() -> Graph<'static> {
537 let mut g = Graph::new();
538 g.add_node(1usize, "Root");
539 g.add_node(2usize, BoxedNode("Boxed"));
540 g.add_node(
541 3usize,
542 CustomNode {
543 label: "card",
544 width: 9,
545 height: 3,
546 painter: None,
547 payload: "row1;row2",
548 },
549 );
550 g.add_node(4usize, "Mid");
551 g.add_edge(1usize, 2usize, None);
552 g.add_edge(1usize, 3usize, None);
553 g.add_edge(2usize, 4usize, None);
554 g.add_edge(3usize, 4usize, None);
555 g.add_edge(4usize, 5usize, Some("ships")); g.add_edge(1usize, 5usize, None); g.add_edge(4usize, 4usize, Some("retry")); let sg = g.add_subgraph("grp");
559 g.put_nodes(&[2, 3]).inside(sg).unwrap();
560 g
561 }
562
563 fn layout_config(direction: crate::graph::Direction) -> crate::LayoutConfig<'static> {
564 let mut cfg = crate::LayoutConfig::standard();
565 cfg.direction = direction;
566 cfg.include_dummy_nodes = true;
567 cfg
568 }
569
570 fn with_both_backend_scenes(
573 direction: crate::graph::Direction,
574 options: &PlanOptions,
575 check: &mut dyn FnMut(&Scene<'_, '_>),
576 ) {
577 let cfg = layout_config(direction);
578
579 let g = corpus_graph();
580 let heap_ir = g.compute_layout_with_config(&cfg);
581 let mut planner = ScenePlanner::new();
582 check(&planner.plan(&heap_ir, options).quiet().unwrap());
583
584 let g = corpus_graph();
585 let mut csr_buf = vec![0u8; g.estimate_csr_arena_size() * 2];
586 let mut csr_arena = Arena::new(&mut csr_buf);
587 let csr = g.to_csr(&mut csr_arena).unwrap();
588 let size = (g.estimate_layout_arena_size() * 2).max(256 * 1024);
589 let mut temp_buf = vec![0u8; size];
590 let mut out_buf = vec![0u8; size];
591 let mut temp_arena = Arena::new(&mut temp_buf);
592 let mut out_arena = Arena::new(&mut out_buf);
593 let arena_ir = csr
594 .compute_layout_arena(&cfg, &mut temp_arena, &mut out_arena)
595 .unwrap();
596 check(&planner.plan(&arena_ir, options).quiet().unwrap());
597 }
598
599 #[test]
604 fn every_view_type_is_copy() {
605 fn assert_copy<T: Copy>() {}
606 assert_copy::<NodeView<'static>>();
607 assert_copy::<EdgeView<'static>>();
608 assert_copy::<EdgePathView<'static>>();
609 assert_copy::<LabelView<'static>>();
610 assert_copy::<SubgraphView<'static>>();
611 assert_copy::<NodeKind>();
612 assert_copy::<NodeOrigin>();
613 assert_copy::<LabelSlot>();
614 }
615
616 #[test]
621 fn views_agree_across_backends() {
622 #[cfg_attr(not(feature = "layout-horizontal"), allow(unused_mut))]
623 let mut directions = vec![
624 crate::graph::Direction::TopDown,
625 crate::graph::Direction::BottomUp,
626 ];
627 #[cfg(feature = "layout-horizontal")]
628 directions.extend([
629 crate::graph::Direction::LeftRight,
630 crate::graph::Direction::RightLeft,
631 ]);
632
633 for dir in directions {
634 let options = PlanOptions::new().with_show_dummy_nodes(true);
635 let mut per_backend: Vec<Vec<String>> = Vec::new();
636 with_both_backend_scenes(dir, &options, &mut |scene| {
637 let mut prints: Vec<String> = Vec::new();
638 prints.extend(scene.nodes().map(|n| format!("{n:?}")));
639 prints.extend(scene.edges().map(|e| format!("{e:?}")));
640 prints.extend(scene.subgraphs().map(|s| format!("{s:?}")));
641 prints.sort();
642 per_backend.push(prints);
643 });
644 let [heap, arena] = per_backend.as_slice() else {
645 panic!("expected two backends");
646 };
647 assert_eq!(heap, arena, "view parity failed for {dir:?}");
648
649 let all = heap.join("\n");
651 assert!(all.contains("waypoints: ["), "no MultiSegment:\n{all}");
652 assert!(all.contains("\"ships\""), "edge label missing:\n{all}");
653 assert!(all.contains("\"row1;row2\""), "payload missing:\n{all}");
654 assert!(all.contains("Dummy"), "no dummy views:\n{all}");
655 assert!(all.contains("SelfLoop"), "no self-loop view:\n{all}");
656 }
657 }
658
659 #[test]
662 fn views_construct_without_allocation() {
663 fn fold(acc: u64, v: u64) -> u64 {
664 (acc ^ v).wrapping_mul(0x100000001b3)
665 }
666 fn fold_str(mut acc: u64, s: &str) -> u64 {
667 for b in s.bytes() {
668 acc = fold(acc, u64::from(b));
669 }
670 acc
671 }
672
673 let options = PlanOptions::new().with_show_dummy_nodes(true);
674 with_both_backend_scenes(crate::graph::Direction::TopDown, &options, &mut |scene| {
675 let mut acc = 0u64;
676 let before = allocations_on_this_thread();
677 for n in scene.nodes() {
678 acc = fold_str(fold(acc, n.id.unwrap_or(0) as u64), n.label);
679 if let Some(p) = n.payload {
680 acc = fold_str(acc, p);
681 }
682 }
683 for e in scene.edges() {
684 acc = fold(acc, e.input_index as u64);
685 if let EdgePathView::MultiSegment { waypoints, .. } = e.path {
686 for &(x, y) in waypoints {
687 acc = fold(fold(acc, x as u64), y as u64);
688 }
689 }
690 if let Some(l) = e.label {
691 acc = fold_str(acc, l.text);
692 }
693 }
694 for s in scene.subgraphs() {
695 acc = fold_str(fold(acc, s.id as u64), s.label);
696 }
697 let after = allocations_on_this_thread();
698 std::hint::black_box(acc);
699 assert_eq!(after - before, 0, "view construction allocated");
700 });
701 }
702
703 #[test]
706 fn spline_normalizes_to_direct() {
707 let literal_node = |id: usize, label: &'static str, y: usize| crate::ir::LayoutNode {
708 id,
709 label,
710 x: 0,
711 y,
712 width: 3,
713 height: 1,
714 center_x: 1,
715 center_y: y,
716 level: y / 2,
717 level_position: 0,
718 kind: crate::ir::NodeKind::Explicit,
719 has_self_loop: false,
720 self_loop_at: None,
721 edge_index: None,
722 content_tag: 0,
723 };
724 let mut b = crate::ir::LayoutIRBuilder::new().with_levels(2);
725 b.add_node(literal_node(0, "a", 0));
726 b.add_node(literal_node(1, "b", 2));
727 b.add_edge(crate::ir::LayoutEdge {
728 from_id: 0,
729 to_id: 1,
730 from_port: crate::PortAttachment::auto(crate::PhysicalSide::South),
731 to_port: crate::PortAttachment::auto(crate::PhysicalSide::North),
732 from_x: 1,
733 from_y: 0,
734 to_x: 1,
735 to_y: 2,
736 path: crate::ir::EdgePath::Spline {
737 cp1_x: 1,
738 cp1_y: 2,
739 cp2_x: 3,
740 cp2_y: 4,
741 },
742 flow_axis: crate::ir::FlowAxis::Y,
743 edge_index: 0,
744 label: None,
745 label_x: 0,
746 label_y: 0,
747 directed: true,
748 reversed: false,
749 });
750 b.set_dimensions(8, 3);
751 let ir = b.build();
752 let mut planner = ScenePlanner::new();
753 let scene = planner.plan(&ir, &PlanOptions::new()).quiet().unwrap();
754 assert_eq!(scene.edge(0).unwrap().path, EdgePathView::Direct);
755 assert!(scene.edge(1).is_none(), "one edge only");
756 }
757
758 #[test]
762 fn dummy_visibility_follows_the_plan() {
763 let g = corpus_graph();
764 let ir = g.compute_layout_with_config(&layout_config(crate::graph::Direction::TopDown));
765 let mut planner = ScenePlanner::new();
766
767 let hidden = planner.plan(&ir, &PlanOptions::new()).quiet().unwrap();
768 assert!(
769 hidden.nodes().all(|n| !matches!(n.kind, NodeKind::Dummy)),
770 "dummies hidden by default"
771 );
772 drop(hidden);
773
774 let shown = planner
775 .plan(&ir, &PlanOptions::new().with_show_dummy_nodes(true))
776 .quiet()
777 .unwrap();
778 let dummies: Vec<NodeView<'_>> = shown
779 .nodes()
780 .filter(|n| matches!(n.kind, NodeKind::Dummy))
781 .collect();
782 assert!(!dummies.is_empty(), "skip edge must produce dummies");
783 for d in &dummies {
784 assert_eq!(d.id, None);
785 assert!(d.dummy_of.is_some());
786 assert!(d.label.is_empty());
787 }
788 assert!(shown.node(1).is_some());
789 assert_eq!(
790 shown.node(1).unwrap().kind,
791 NodeKind::Real {
792 origin: NodeOrigin::Declared
793 }
794 );
795 assert_eq!(
796 shown.node(5).unwrap().kind,
797 NodeKind::Real {
798 origin: NodeOrigin::EdgeInferred
799 }
800 );
801 }
802
803 fn labeled_graph() -> Graph<'static> {
806 let mut g = Graph::new();
807 g.add_node(1usize, "A");
808 g.add_node(2usize, "B");
809 g.add_node(3usize, "C");
810 g.add_edge(
811 1usize,
812 2usize,
813 Some("an-extremely-long-label-that-cannot-possibly-fit-inline-anywhere-at-all"),
814 );
815 g.add_edge(1usize, 3usize, Some("ok"));
816 g
817 }
818
819 #[test]
824 fn label_slots_reflect_policy() {
825 let ir = labeled_graph().compute_layout();
826 let mut planner = ScenePlanner::new();
827
828 let overflow_legend = PlanOptions::new()
829 .with_label_policy(LabelPolicy::new().with_overflow(LabelOverflow::Legend));
830 let scene = planner.plan(&ir, &overflow_legend).quiet().unwrap();
831 let mut inline = 0usize;
832 let mut legend = Vec::new();
833 for e in scene.edges() {
834 match e.label.map(|l| l.slot) {
835 Some(LabelSlot::Inline { len, .. }) => {
836 assert!(len > 0);
837 inline += 1;
838 }
839 Some(LabelSlot::Legend) => legend.push(e.scene_index),
840 Some(LabelSlot::Omitted) => panic!("Legend overflow never omits"),
841 None => {}
842 }
843 }
844 assert!(inline > 0, "the short label places inline");
845 assert!(!legend.is_empty(), "the giant label overflows");
846 assert_eq!(legend, scene.legend_entries().to_vec());
847 let legend_views: Vec<usize> = scene.legend().map(|e| e.scene_index).collect();
848 assert_eq!(legend, legend_views);
849 drop(scene);
850
851 let scene = planner.plan(&ir, &PlanOptions::new()).quiet().unwrap();
854 let omitted = scene
855 .edges()
856 .filter(|e| matches!(e.label.map(|l| l.slot), Some(LabelSlot::Omitted)))
857 .count();
858 assert_eq!(omitted, legend.len(), "unplaced labels become Omitted");
859 assert!(scene.legend_entries().is_empty());
860 assert_eq!(scene.legend().count(), 0);
861 }
862}
863
864#[cfg(all(test, feature = "std", feature = "layout-vertical"))]
865mod self_loop_tests {
866 use super::super::config::{LabelOverflow, LabelPolicy, PlanOptions};
867 use super::super::plan::HitResult;
868 use super::super::scene::ScenePlanner;
869 use super::*;
870 use crate::graph::Graph;
871
872 fn divergent_graph() -> Graph<'static> {
876 let mut g = Graph::new();
877 g.add_node(1usize, "Gate");
878 g.add_node(2usize, "Mid");
879 g.add_node(3usize, "End");
880 g.add_edge(1usize, 1usize, Some("retry")); g.add_edge(1usize, 2usize, None); g.add_edge(2usize, 3usize, None); g.add_edge(1usize, 3usize, None); g
885 }
886
887 #[test]
891 fn self_loop_identity_survives_to_the_scene() {
892 let ir = divergent_graph().compute_layout();
893 let mut planner = ScenePlanner::new();
894 let options = PlanOptions::new()
895 .with_label_policy(LabelPolicy::new().with_overflow(LabelOverflow::Legend));
896 let scene = planner.plan(&ir, &options).quiet().unwrap();
897
898 assert_eq!(scene.edges().count(), 4, "3 routed + 1 loop");
899 let luup = scene.edge(3).expect("loop sits after the routed list");
900 assert_eq!(luup.scene_index, 3);
901 assert_eq!(luup.input_index, 0, "original insertion index");
902 assert_eq!((luup.from_id, luup.to_id), (1, 1));
903 let EdgePathView::SelfLoop { at } = luup.path else {
904 panic!("loop path is the marker cell");
905 };
906 assert_eq!(luup.from, at);
907 let label = luup.label.expect("label preserved");
908 assert_eq!(label.text, "retry");
909 assert_eq!(label.slot, LabelSlot::Legend);
910 assert_eq!(scene.legend().map(|e| e.scene_index).next(), Some(3));
911
912 assert_eq!(scene.hit_test(at.0, at.1), HitResult::Edge(3));
914 }
915
916 #[test]
919 fn style_callbacks_run_for_self_loops() {
920 fn styled(ctx: super::super::style::EdgeStyleCtx<'_>) -> super::super::style::EdgeStyle {
921 if ctx.from_id == ctx.to_id {
922 assert_eq!(ctx.edge_index, 0, "loop keeps its input index");
923 assert_eq!(ctx.label, Some("retry"));
924 super::super::style::EdgeStyle {
925 color: CellColor::ansi256(199),
926 ..Default::default()
927 }
928 } else {
929 super::super::style::EdgeStyle::default()
930 }
931 }
932 let ir = divergent_graph().compute_layout();
933 let mut planner = ScenePlanner::new();
934 let scene = planner
935 .plan(&ir, &PlanOptions::new().with_edge_style_fn(styled))
936 .quiet()
937 .unwrap();
938 assert_eq!(scene.edge(3).unwrap().color, CellColor::ansi256(199));
939 }
940
941 #[test]
945 fn routed_palette_never_shifts() {
946 let mut without = Graph::new();
947 without.add_node(1usize, "Gate");
948 without.add_node(2usize, "Mid");
949 without.add_node(3usize, "End");
950 without.add_edge(1usize, 2usize, None);
951 without.add_edge(2usize, 3usize, None);
952 without.add_edge(1usize, 3usize, None);
953
954 let ir_without = without.compute_layout();
955 let ir_with = divergent_graph().compute_layout();
956 let mut planner = ScenePlanner::new();
957 let options = PlanOptions::new();
958
959 let colors_without: Vec<CellColor> = {
960 let scene = planner.plan(&ir_without, &options).quiet().unwrap();
961 (0..3).map(|i| scene.edge(i).unwrap().color).collect()
962 };
963 let scene = planner.plan(&ir_with, &options).quiet().unwrap();
964 let colors_with: Vec<CellColor> = (0..3).map(|i| scene.edge(i).unwrap().color).collect();
965 assert_eq!(colors_without, colors_with, "routed palette must not shift");
966 }
967}