Skip to main content

ascii_dag/render/engine/
views.rs

1//! Scene element views — storage-neutral, read-only projections.
2//!
3//! Every view is a small `Copy` value constructed on demand from the
4//! scene's private lens: no view is materialized or stored, borrowed
5//! text and waypoints LEND from whichever IR backend produced the
6//! layout (zero allocation, pinned by test), and no heap-only type can
7//! appear in a field (`Copy` is the guardrail; the cross-backend
8//! construction tests are the proof). Both backends produce
9//! field-for-field identical views.
10
11use 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/// Real-vs-dummy: the scene's top-level node dichotomy (deliberately
20/// EXHAUSTIVE — a closed promise, matchable without a wildcard).
21/// Provenance detail lives inside [`Real`](Self::Real) and can grow
22/// without disturbing real/dummy matches.
23///
24/// This is the scene vocabulary; the IR keeps its flat
25/// [`ir::NodeKind`](crate::ir::NodeKind) (`Explicit | Implicit |
26/// Dummy`) untouched until the 0.12 IR overhaul.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum NodeKind {
29    /// A node of the input graph.
30    Real {
31        /// How the node entered the graph.
32        origin: NodeOrigin,
33    },
34    /// A routing waypoint synthesized by layout. Appears in
35    /// [`Scene::nodes`] only when
36    /// [`PlanOptions::show_dummy_nodes`](super::config::PlanOptions::show_dummy_nodes)
37    /// is set (matching what paints and what hit-tests).
38    Dummy,
39}
40
41/// How a real node entered the graph.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum NodeOrigin {
45    /// Declared via `add_node`.
46    Declared,
47    /// Auto-created by an edge referencing an undeclared id.
48    EdgeInferred,
49}
50
51/// One node of the scene.
52#[non_exhaustive]
53#[derive(Debug, Clone, Copy)]
54pub struct NodeView<'s> {
55    /// Graph id — `None` for dummies: their raw ids are synthetic and
56    /// backend-specific, so the scene never exposes them. Dummies
57    /// carry [`dummy_of`](Self::dummy_of) instead.
58    pub id: Option<usize>,
59    /// Backend-stable dummy identity: the owning edge's INPUT index
60    /// (the style-callback / [`EdgeView::input_index`] convention) and
61    /// the level the dummy occupies — the same pair
62    /// [`HitResult::Dummy`](super::plan::HitResult::Dummy) reports.
63    /// `None` for real nodes.
64    pub dummy_of: Option<(usize, usize)>,
65    /// Left edge, in cells.
66    pub x: usize,
67    /// Top edge, in rows.
68    pub y: usize,
69    /// Width in cells (including brackets/padding).
70    pub width: usize,
71    /// Height in rows.
72    pub height: usize,
73    /// Real-vs-dummy dichotomy.
74    pub kind: NodeKind,
75    /// Label text (empty for dummies).
76    pub label: &'s str,
77    /// Declared content kind (simple / boxed / custom).
78    pub content: NodeKindTag,
79    /// Custom-node payload — the **data** half of the template/data
80    /// pair, rejoined by the scene ("geometry and keyed content meet
81    /// again here"). `None` when the node declared no custom content.
82    pub payload: Option<&'s str>,
83}
84
85/// One edge of the scene, fully resolved (style callbacks already
86/// ran, exactly once, at plan time).
87#[non_exhaustive]
88#[derive(Debug, Clone, Copy)]
89pub struct EdgeView<'s> {
90    /// Position in the scene's edge list — the hit-testing, legend,
91    /// and palette-default convention
92    /// ([`HitResult::Edge`](super::plan::HitResult::Edge)).
93    pub scene_index: usize,
94    /// Original graph insertion index — the style-callback convention
95    /// ([`EdgeStyleCtx::edge_index`](super::style::EdgeStyleCtx)).
96    /// The two diverge when self-loops exist (self-loops are absent
97    /// from the routed list).
98    pub input_index: usize,
99    /// Source node id.
100    pub from_id: usize,
101    /// Target node id.
102    pub to_id: usize,
103    /// Whether an arrowhead was requested.
104    pub directed: bool,
105    /// Reversed during cycle breaking (renders dashed by default).
106    pub reversed: bool,
107    /// Resolved stroke color (plain emission ignores it).
108    pub color: CellColor,
109    /// Resolved stroke weight.
110    pub weight: LineWeight,
111    /// Marker at the LOGICAL source end. Which geometric end paints
112    /// follows [`reversed`](Self::reversed) — the view exposes both
113    /// markers plus `reversed` so consumers can reconstruct either
114    /// framing.
115    pub marker_source: MarkerShape,
116    /// Marker at the LOGICAL target end.
117    pub marker_target: MarkerShape,
118    /// Routed source anchor, physical `(x, y)`.
119    pub from: (usize, usize),
120    /// Routed target anchor, physical `(x, y)`.
121    pub to: (usize, usize),
122    /// Routed geometry.
123    pub path: EdgePathView<'s>,
124    /// Physical axis of the edge's trunk.
125    pub flow_axis: FlowAxis,
126    /// How the end at the declared source is attached — the requested
127    /// side and the physical side it landed on (`from` is the cell).
128    pub from_port: crate::PortAttachment,
129    /// How the end at the declared target is attached (`to` is the cell).
130    pub to_port: crate::PortAttachment,
131    /// The edge's label, if it declared one — with its resolved color
132    /// and where it ended up (inline, legend, or omitted).
133    pub label: Option<LabelView<'s>>,
134}
135
136/// Storage-neutral routed path. The heap IR's `Vec` waypoints and the
137/// arena IR's shared-slice waypoints both LEND here — no IR type
138/// appears in the scene surface.
139#[non_exhaustive]
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum EdgePathView<'s> {
142    /// Straight flow segment.
143    Direct,
144    /// L-shaped connection with one cross-axis segment at `bend_at`.
145    Corner {
146        /// The level-axis line the cross segment runs on.
147        bend_at: usize,
148    },
149    /// Routed through a far cross-axis channel.
150    SideChannel {
151        /// Cross-axis line of the channel.
152        channel_at: usize,
153        /// Level-axis start of the channel span.
154        span_start: usize,
155        /// Level-axis end of the channel span.
156        span_end: usize,
157    },
158    /// Multi-segment path through routing waypoints — physical
159    /// `(x, y)` cells, lent from the IR.
160    MultiSegment {
161        /// The waypoint cells, in path order.
162        waypoints: &'s [(usize, usize)],
163        /// Level-axis offset of the first bend past the source.
164        start_offset: usize,
165    },
166    #[cfg(feature = "ports")]
167    /// An explicit orthogonal polyline — every turn stated, lent from
168    /// the IR as physical `(x, y)` bend cells (see
169    /// [`EdgePath::Orthogonal`](crate::ir::EdgePath::Orthogonal)).
170    Orthogonal {
171        /// The bend cells, in path order.
172        bends: &'s [(usize, usize)],
173    },
174    /// A preserved self-loop: no routed path, one marker cell.
175    SelfLoop {
176        /// The `↺` marker cell.
177        at: (usize, usize),
178    },
179}
180
181/// An edge label, resolved: text, color, and where it landed.
182#[non_exhaustive]
183#[derive(Debug, Clone, Copy)]
184pub struct LabelView<'s> {
185    /// Label text (never transliterated).
186    pub text: &'s str,
187    /// Resolved label color.
188    pub color: CellColor,
189    /// Where the label ended up under the plan's label policy.
190    pub slot: LabelSlot,
191}
192
193/// Where a label landed.
194#[non_exhaustive]
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum LabelSlot {
197    /// Painted inline at `(x, y)`, spanning `len` cells (quotes
198    /// included).
199    Inline {
200        /// Left cell of the painted span.
201        x: usize,
202        /// Row of the painted span.
203        y: usize,
204        /// Painted length in cells.
205        len: usize,
206    },
207    /// Overflowed to the legend
208    /// ([`LabelOverflow::Legend`](super::config::LabelOverflow::Legend)).
209    Legend,
210    /// Unplaceable, and the policy says omit — the label appears
211    /// nowhere in the output.
212    Omitted,
213}
214
215/// One cluster of the scene.
216#[non_exhaustive]
217#[derive(Debug, Clone, Copy)]
218pub struct SubgraphView<'s> {
219    /// Subgraph id.
220    pub id: usize,
221    /// Immediate parent cluster, if nested.
222    pub parent: Option<usize>,
223    /// Cluster label.
224    pub label: &'s str,
225    /// Left edge, in cells.
226    pub x: usize,
227    /// Top edge, in rows.
228    pub y: usize,
229    /// Width in cells.
230    pub width: usize,
231    /// Height in rows.
232    pub height: usize,
233    /// Resolved border style.
234    pub border: SubgraphBorder,
235    /// Resolved border color.
236    pub color: CellColor,
237    /// Resolved label position.
238    pub label_position: LabelPosition,
239}
240
241/// Dispatch one expression over both lens backends.
242macro_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    /// The scene's nodes. Dummies (routing waypoints) appear only
254    /// when the scene was planned with `show_dummy_nodes` — matching
255    /// what paints and what hit-tests.
256    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    /// One node by its graph id (real nodes only — dummies have no
265    /// graph id; find them via [`nodes`](Self::nodes)).
266    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    /// The scene's edges, in scene order: routed edges first, then
274    /// preserved self-loops (identity, label, and resolved style
275    /// intact — style callbacks ran for them at plan time).
276    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    /// One edge by scene index (self-loops sit after the routed list).
281    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    /// The scene's clusters, in declaration order.
290    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    /// Edges whose labels overflowed to the legend, in emission order
296    /// (the same list [`legend_entries`](Self::legend_entries)
297    /// indexes) — self-loop labels included: they have no inline
298    /// placement host, so a labeled loop always lands here (or is
299    /// omitted, per the overflow policy).
300    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                // The lens yields `(None, "")` for nodes without a
335                // custom entry; a fully blank custom node stores no
336                // entry, so this mapping is lossless.
337                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                    // The IR's `Spline` variant is a forward-compat
370                    // stub the layout engine never emits; the
371                    // compositor falls back to `Direct`, and the view
372                    // boundary does the same.
373                    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    /// Synthesize the view of preserved self-loop `j` (scene index
407    /// `scene_index`). The marker cell anchors both endpoints; the
408    /// label — which has no inline placement host — reports where it
409    /// actually went (legend or omitted).
410    fn self_loop_view_at(&self, scene_index: usize, j: usize) -> EdgeView<'_> {
411        /// The side the `↺` cell sits on: one cell past the node on
412        /// the cross axis — East under vertical flows, South under
413        /// horizontal ones.
414        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            // O(1) record→node join through the record's stored index.
424            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                // A preserved loop has no routed ends: both report the
448                // marker's side (past the node on the cross axis), as
449                // undeclared — declared sides on loops are deferred.
450                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
486/// The physical trunk axis for a given rank direction (an `if`
487/// rather than a `match` so each single-axis build sees only the
488/// variants it has).
489fn 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
501/// Where edge `index`'s label landed under this plan. Both plan lists
502/// are built in ascending edge order, so lookups are binary searches.
503fn 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        // An edge with label text but no label plan cannot happen for
507        // routed edges today; be conservative rather than panic.
508        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    /// Plain, boxed, custom-payload, and auto-created nodes; a labeled
535    /// edge; a level-skipping edge (waypoints); a cluster.
536    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")); // 5 auto-created
556        g.add_edge(1usize, 5usize, None); // skips levels → waypoints
557        g.add_edge(4usize, 4usize, Some("retry")); // preserved self-loop
558        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    /// Run `check` once per backend, over a scene planned with
571    /// `options`.
572    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    /// No owning type can be a view field — `Vec`, `String`, and every
600    /// other heap-only type fails the `Copy` bound at compile time
601    /// (the guardrail; the cross-backend construction below is the
602    /// proof).
603    #[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    /// Both backends produce field-for-field identical views — the
617    /// `Debug` fingerprints cover EVERY public field, with no
618    /// exclusions (dummy identity included), in every enabled
619    /// direction.
620    #[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            // The corpus must actually exercise the lending paths.
650            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    /// Constructing and reading every view performs zero allocations
660    /// on both backends — text, payloads, and waypoints all LEND.
661    #[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    /// The IR's `Spline` stub never reaches the public view: it
704    /// normalizes to `Direct`, the compositor's own fallback.
705    #[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    /// Dummies appear in `nodes()` exactly when the scene shows them —
759    /// with `id: None` and a backend-stable `dummy_of` identity —
760    /// and `node(id)` finds real nodes only.
761    #[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    /// Two labels, one placeable and one that fits nowhere (longer
804    /// than anything the canvas offers).
805    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    /// Label slots mirror the plan's policy exactly: `Inline` iff the
820    /// label paints, `Legend` iff it overflowed to the legend list,
821    /// `Omitted` otherwise — and `legend()` yields exactly the
822    /// overflowed edges, in emission order.
823    #[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        // Same placement, Omit overflow: the same unplaced labels are
852        // now Omitted and the legend is empty.
853        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    /// Loop FIRST, so input and scene indices diverge: input 0 is the
873    /// loop, inputs 1..3 are routed edges at scene 0..2, and the loop's
874    /// scene index is 3 (after the routed list).
875    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")); // input 0: self-loop
881        g.add_edge(1usize, 2usize, None); // input 1 → scene 0
882        g.add_edge(2usize, 3usize, None); // input 2 → scene 1
883        g.add_edge(1usize, 3usize, None); // input 3 → scene 2
884        g
885    }
886
887    /// The preserved loop is a full scene edge: both index conventions
888    /// named, label carried, marker as its path — and the `↺` cell
889    /// hit-tests as the loop EDGE, not the node.
890    #[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        // The marker cell belongs to the loop EDGE now.
913        assert_eq!(scene.hit_test(at.0, at.1), HitResult::Edge(3));
914    }
915
916    /// Style callbacks run for preserved loops — with the loop's own
917    /// input identity — and their answers land in the view.
918    #[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    /// Acceptance: routed-list palette indices NEVER shift — inserting
942    /// a self-loop (even first) leaves every routed edge's default
943    /// color exactly as the loop-free graph resolves it.
944    #[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}