Skip to main content

piw/
render.rs

1//! Port of `src/render/graph-render.ts`: renders the workflow DAG onto a
2//! `CharCanvas`. Statuses derive from the steps visible up to the selected
3//! step, taken transitions highlight, switch branches carry case labels, and
4//! loop edges route through a right-hand gutter. Output is pinned to the
5//! TypeScript renderer through the golden fixtures.
6
7use crate::bundle::types::{
8    DefinitionSnapshot, EdgeDef, NodeOutcome, RunState, RunStatus, StepRecord,
9};
10use crate::canvas::{CanvasStyle, CharCanvas};
11use crate::format::{format_duration, parse_timestamp_ms, sanitize_text};
12use crate::layout::{layout_graph, GraphCell, GraphEdge, GraphLayout, GraphSegment};
13use serde_json::Value;
14use std::collections::HashSet;
15
16/// Everything the graph needs from a loaded bundle.
17pub struct GraphView<'a> {
18    pub state: &'a RunState,
19    pub snapshot: Option<&'a DefinitionSnapshot>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum NodeStatus {
24    Completed,
25    Failed,
26    TimedOut,
27    Active,
28    ReplayFocus,
29    Waiting,
30    Queued,
31    Cancelled,
32}
33
34impl NodeStatus {
35    fn glyph(self) -> char {
36        match self {
37            NodeStatus::Completed => '✓',
38            NodeStatus::Failed => '✗',
39            NodeStatus::TimedOut => '×',
40            NodeStatus::Active => '◐',
41            NodeStatus::ReplayFocus => '◆',
42            NodeStatus::Waiting => '⏸',
43            NodeStatus::Cancelled => '~',
44            NodeStatus::Queued => '·',
45        }
46    }
47
48    fn label(self) -> &'static str {
49        match self {
50            NodeStatus::Completed => "completed",
51            NodeStatus::Failed => "failed",
52            NodeStatus::TimedOut => "timed out",
53            NodeStatus::Active => "running",
54            NodeStatus::ReplayFocus => "replay focus",
55            NodeStatus::Waiting => "waiting",
56            NodeStatus::Cancelled => "cancelled",
57            NodeStatus::Queued => "queued",
58        }
59    }
60
61    fn style(self) -> CanvasStyle {
62        match self {
63            NodeStatus::Completed => CanvasStyle::Ok,
64            NodeStatus::Failed => CanvasStyle::Fail,
65            NodeStatus::TimedOut => CanvasStyle::TimedOut,
66            NodeStatus::Active => CanvasStyle::Active,
67            NodeStatus::ReplayFocus => CanvasStyle::Replay,
68            NodeStatus::Waiting => CanvasStyle::Warn,
69            NodeStatus::Cancelled => CanvasStyle::Cancelled,
70            NodeStatus::Queued => CanvasStyle::NodeDim,
71        }
72    }
73
74    fn border_style(self) -> CanvasStyle {
75        match self {
76            NodeStatus::Completed => CanvasStyle::NodeBorderOk,
77            NodeStatus::Failed => CanvasStyle::NodeBorderFail,
78            NodeStatus::TimedOut => CanvasStyle::NodeBorderTimedOut,
79            NodeStatus::Active => CanvasStyle::NodeBorderActive,
80            NodeStatus::ReplayFocus => CanvasStyle::NodeBorderReplay,
81            NodeStatus::Waiting => CanvasStyle::NodeBorderWarn,
82            NodeStatus::Cancelled => CanvasStyle::NodeBorderCancelled,
83            NodeStatus::Queued => CanvasStyle::NodeBorderDim,
84        }
85    }
86
87    fn is_focused(self) -> bool {
88        matches!(self, NodeStatus::Active | NodeStatus::ReplayFocus)
89    }
90}
91
92const CELL_GAP: i64 = 6;
93const GUTTER_GAP: i64 = 2;
94const GRAPH_SIDE_MARGIN: i64 = 2;
95const CARD_MIN_CONTENT_WIDTH: i64 = 28;
96const CARD_DYNAMIC_RESERVE: &str = "↻ 100  ◷ 9999d 23h 59m 59s";
97
98fn node_type_glyph(node_type: &str) -> char {
99    match node_type {
100        "agent" => '●',
101        "compute" => 'ƒ',
102        "notify" => '✉',
103        "action" => '⚙',
104        "checkpoint" => '◆',
105        _ => '?',
106    }
107}
108
109fn node_type_style(node_type: &str, focused: bool) -> CanvasStyle {
110    match (node_type, focused) {
111        ("agent", false) => CanvasStyle::Agent,
112        ("agent", true) => CanvasStyle::AgentFocus,
113        ("compute", false) => CanvasStyle::Compute,
114        ("compute", true) => CanvasStyle::ComputeFocus,
115        ("notify", false) => CanvasStyle::Action,
116        ("notify", true) => CanvasStyle::ActionFocus,
117        ("action", false) => CanvasStyle::Action,
118        ("action", true) => CanvasStyle::ActionFocus,
119        ("checkpoint", false) => CanvasStyle::Checkpoint,
120        ("checkpoint", true) => CanvasStyle::CheckpointFocus,
121        (_, false) => CanvasStyle::NodeDim,
122        (_, true) => CanvasStyle::NodeFocusText,
123    }
124}
125
126fn node_type_badge(node_type: &str) -> String {
127    format!("{} {node_type}", node_type_glyph(node_type))
128}
129
130fn fit_text(text: &str, width: usize) -> String {
131    let chars: Vec<char> = text.chars().collect();
132    if chars.len() <= width {
133        return text.to_string();
134    }
135    if width <= 1 {
136        return chars.into_iter().take(width).collect();
137    }
138    format!("{}…", chars.into_iter().take(width - 1).collect::<String>())
139}
140
141fn centered_text(text: &str, width: usize) -> String {
142    let fitted = fit_text(text, width);
143    let left = width.saturating_sub(fitted.chars().count()) / 2;
144    format!("{}{fitted}", " ".repeat(left))
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum GraphNodeStyle {
149    Line,
150    Box,
151}
152
153#[derive(Debug, Clone, Copy)]
154struct CardMetrics {
155    width: i64,
156    height: i64,
157    branch_rows: usize,
158}
159
160fn cell_height(node_style: GraphNodeStyle, box_height: i64) -> i64 {
161    match node_style {
162        GraphNodeStyle::Box => box_height,
163        GraphNodeStyle::Line => 1,
164    }
165}
166
167/// JS `String.prototype.length` (UTF-16 code units), used for width math.
168fn js_len(text: &str) -> i64 {
169    text.encode_utf16().count() as i64
170}
171
172fn latest_visible_attempt<'a>(steps: &'a [StepRecord], node_id: &str) -> Option<&'a StepRecord> {
173    steps.iter().rev().find(|step| step.node_id == node_id)
174}
175
176fn derive_node_status(
177    view: &GraphView,
178    node_id: &str,
179    visible_steps: &[StepRecord],
180    at_latest_step: bool,
181) -> NodeStatus {
182    let state = view.state;
183    if at_latest_step && state.current_node.as_deref() == Some(node_id) {
184        return NodeStatus::Active;
185    }
186    if at_latest_step && state.waiting_on.as_deref() == Some(node_id) {
187        return NodeStatus::Waiting;
188    }
189    let Some(attempt) = latest_visible_attempt(visible_steps, node_id) else {
190        return NodeStatus::Queued;
191    };
192    // While scrubbing, the selected step is a replay cursor, not a live node.
193    if !at_latest_step && visible_steps.last().map(|step| step.node_id.as_str()) == Some(node_id) {
194        return NodeStatus::ReplayFocus;
195    }
196    match attempt.outcome {
197        NodeOutcome::Ok => NodeStatus::Completed,
198        NodeOutcome::TimedOut => NodeStatus::TimedOut,
199        NodeOutcome::Cancelled => NodeStatus::Cancelled,
200        NodeOutcome::Failed => NodeStatus::Failed,
201    }
202}
203
204fn node_branch_labels(view: &GraphView, node_id: &str) -> Vec<String> {
205    view.snapshot
206        .map(|snapshot| {
207            snapshot
208                .edges
209                .iter()
210                .flat_map(|edge| match edge {
211                    EdgeDef::Switch { from, switch } if from == node_id => switch
212                        .cases
213                        .keys()
214                        .map(|label| sanitize_text(label))
215                        .collect::<Vec<_>>(),
216                    _ => Vec::new(),
217                })
218                .collect()
219        })
220        .unwrap_or_default()
221}
222
223fn card_metrics(view: &GraphView) -> CardMetrics {
224    let Some(snapshot) = view.snapshot else {
225        return CardMetrics {
226            width: CARD_MIN_CONTENT_WIDTH + 4,
227            height: 7,
228            branch_rows: 0,
229        };
230    };
231    let mut content_width = CARD_MIN_CONTENT_WIDTH.max(js_len(CARD_DYNAMIC_RESERVE));
232    let mut branch_rows = 0usize;
233    for status in [
234        NodeStatus::Completed,
235        NodeStatus::Failed,
236        NodeStatus::TimedOut,
237        NodeStatus::Active,
238        NodeStatus::ReplayFocus,
239        NodeStatus::Waiting,
240        NodeStatus::Queued,
241        NodeStatus::Cancelled,
242    ] {
243        content_width = content_width.max(js_len(&format!(
244            "{}  {} {}",
245            node_type_badge("checkpoint"),
246            status.glyph(),
247            status.label()
248        )));
249    }
250    for (node_id, node) in &snapshot.nodes {
251        content_width = content_width.max(js_len(&sanitize_text(node_id)));
252        if let Some(node_type) = node.get("nodeType").and_then(Value::as_str) {
253            content_width = content_width.max(js_len(&node_type_badge(node_type)));
254        }
255        let labels = node_branch_labels(view, node_id);
256        branch_rows = branch_rows.max(labels.len());
257        for label in labels {
258            content_width = content_width.max(js_len(&format!("◇ {label}")));
259        }
260    }
261    CardMetrics {
262        width: content_width + 4,
263        height: 7 + branch_rows as i64,
264        branch_rows,
265    }
266}
267
268struct RenderedCell {
269    cell: GraphCell,
270    text: String,
271    node_id: String,
272    node_type: String,
273    status: Option<NodeStatus>,
274    attempts: usize,
275    elapsed: String,
276    detail: String,
277    branch_lines: Vec<String>,
278    is_start: bool,
279    is_end: bool,
280    width: i64,
281}
282
283fn render_cell_text(
284    view: &GraphView,
285    cell: &GraphCell,
286    visible_steps: &[StepRecord],
287    at_latest_step: bool,
288    now_ms: i64,
289    node_style: GraphNodeStyle,
290    metrics: CardMetrics,
291) -> RenderedCell {
292    let GraphCell::Node { node_id } = cell else {
293        return RenderedCell {
294            cell: cell.clone(),
295            text: String::new(),
296            node_id: String::new(),
297            node_type: String::new(),
298            status: None,
299            attempts: 0,
300            elapsed: String::new(),
301            detail: String::new(),
302            branch_lines: Vec::new(),
303            is_start: false,
304            is_end: false,
305            width: 1,
306        };
307    };
308    let state = view.state;
309    let status = derive_node_status(view, node_id, visible_steps, at_latest_step);
310    let node = view
311        .snapshot
312        .and_then(|snapshot| snapshot.nodes.get(node_id));
313    let node_type = view
314        .snapshot
315        .and_then(|snapshot| snapshot.node_type(node_id))
316        .unwrap_or("?");
317    let attempt = latest_visible_attempt(visible_steps, node_id);
318    let attempts = visible_steps
319        .iter()
320        .filter(|step| step.node_id == *node_id)
321        .count();
322    let labels = node_branch_labels(view, node_id);
323    let outgoing = view.snapshot.map_or(0, |snapshot| {
324        snapshot
325            .edges
326            .iter()
327            .filter_map(|edge| match edge {
328                EdgeDef::Simple { from, .. } if from == node_id => Some(1),
329                EdgeDef::Switch { from, switch } if from == node_id => Some(switch.cases.len()),
330                _ => None,
331            })
332            .sum::<usize>()
333    });
334    let is_start = view
335        .snapshot
336        .is_some_and(|snapshot| snapshot.start_at == *node_id);
337    let is_end = outgoing == 0;
338    let elapsed = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
339        let started_at = state
340            .current_node_started_at
341            .as_deref()
342            .and_then(parse_timestamp_ms)
343            .unwrap_or(now_ms);
344        format_duration(now_ms - started_at)
345    } else if let Some(attempt) = attempt {
346        let duration_ms = parse_timestamp_ms(&attempt.finished_at).unwrap_or(0)
347            - parse_timestamp_ms(&attempt.started_at).unwrap_or(0);
348        format_duration(duration_ms)
349    } else {
350        "—".to_string()
351    };
352    let detail = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
353        state
354            .status_detail
355            .as_deref()
356            .map(sanitize_text)
357            .unwrap_or_default()
358    } else {
359        node.and_then(|node| {
360            node.get("statusDetail")
361                .or_else(|| node.get("summary"))
362                .and_then(Value::as_str)
363        })
364        .map(sanitize_text)
365        .unwrap_or_default()
366    };
367    let mut branch_lines: Vec<String> = labels
368        .into_iter()
369        .map(|label| format!("◇ {label}"))
370        .collect();
371    branch_lines.resize(metrics.branch_rows, String::new());
372    let count = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
373        attempts.max(1)
374    } else {
375        attempts
376    };
377    let timing = if attempt.is_some() || count > 0 {
378        format!(
379            "{count} attempt{} · {elapsed}",
380            if count == 1 { "" } else { "s" }
381        )
382    } else {
383        "not visited".to_string()
384    };
385    let text = format!("{node_id} [{node_type}] {timing}");
386    RenderedCell {
387        cell: cell.clone(),
388        text: text.clone(),
389        node_id: sanitize_text(node_id),
390        node_type: node_type.to_string(),
391        status: Some(status),
392        attempts: count,
393        elapsed,
394        detail,
395        branch_lines,
396        is_start,
397        is_end,
398        width: match node_style {
399            GraphNodeStyle::Box => metrics.width,
400            GraphNodeStyle::Line => js_len(&text) + 2,
401        },
402    }
403}
404
405struct RankGeometry {
406    cells: Vec<RenderedCell>,
407    centers: Vec<i64>,
408}
409
410struct PlacedRank {
411    cells: Vec<RenderedCell>,
412    centers: Vec<i64>,
413    y: i64,
414}
415
416/// A strip segment with final pixel geometry and its assigned track row.
417struct GeomSegment {
418    edge_id: String,
419    label: Option<String>,
420    from_x: i64,
421    to_x: i64,
422    track: i64,
423    target_is_node: bool,
424}
425
426struct StripGeometry {
427    segments: Vec<GeomSegment>,
428    track_count: i64,
429    has_labels: bool,
430    /// True when every segment is an unlabeled vertical line.
431    straight: bool,
432}
433
434/// Transitions actually taken between the visible steps, as "from->to".
435fn taken_transitions(visible_steps: &[StepRecord]) -> HashSet<String> {
436    visible_steps
437        .windows(2)
438        .map(|pair| format!("{}->{}", pair[0].node_id, pair[1].node_id))
439        .collect()
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct NodeBounds {
444    pub node_id: String,
445    pub x: i64,
446    pub y: i64,
447    pub width: i64,
448    pub height: i64,
449}
450
451pub struct RenderedGraph {
452    pub canvas: CharCanvas,
453    pub node_bounds: Vec<NodeBounds>,
454}
455
456/// Render the graph pane and retain node bounds for camera targeting and hit
457/// testing. `selected_step_index` scrubs the replay position;
458/// `at_latest_step` says whether the caller is showing the live view.
459pub fn render_graph(
460    view: &GraphView,
461    selected_step_index: i64,
462    at_latest_step: bool,
463    now_ms: i64,
464    node_style: GraphNodeStyle,
465) -> Option<RenderedGraph> {
466    let snapshot = view.snapshot?;
467    let metrics = card_metrics(view);
468    let layout = layout_graph(snapshot);
469    let steps = &view.state.steps;
470    let bounded_index = selected_step_index.max(-1).min(steps.len() as i64 - 1);
471    let visible_steps = &steps[0..(bounded_index + 1) as usize];
472    let transitions = taken_transitions(visible_steps);
473    let active_pair = derive_pair_in_flight(view, visible_steps, at_latest_step);
474
475    let rendered: Vec<Vec<RenderedCell>> = layout
476        .ranks
477        .iter()
478        .map(|rank| {
479            rank.iter()
480                .map(|cell| {
481                    render_cell_text(
482                        view,
483                        cell,
484                        visible_steps,
485                        at_latest_step,
486                        now_ms,
487                        node_style,
488                        metrics,
489                    )
490                })
491                .collect()
492        })
493        .collect();
494
495    // Column positions: pack cells left to right per rank, then center every
496    // rank against the widest one so vertical edges stay near-vertical.
497    let rank_widths: Vec<i64> = rendered
498        .iter()
499        .map(|cells| {
500            cells.iter().map(|cell| cell.width).sum::<i64>()
501                + 0.max(cells.len() as i64 - 1) * CELL_GAP
502        })
503        .collect();
504    let graph_width = rank_widths.iter().copied().max().unwrap_or(0).max(0) + GRAPH_SIDE_MARGIN * 2;
505    let geometry: Vec<RankGeometry> = rendered
506        .into_iter()
507        .enumerate()
508        .map(|(rank_index, cells)| {
509            let mut centers = Vec::with_capacity(cells.len());
510            let mut x = (graph_width - rank_widths[rank_index]) / 2;
511            for cell in &cells {
512                // Single-cell ranks share the exact graph center so chains
513                // render as straight vertical lines instead of elbows.
514                centers.push(if cells.len() == 1 {
515                    graph_width / 2
516                } else {
517                    x + cell.width / 2
518                });
519                x += cell.width + CELL_GAP;
520            }
521            RankGeometry { cells, centers }
522        })
523        .collect();
524
525    // Horizontal edge geometry (exit/entry columns, pixel-space track rows)
526    // is fully decided before vertical placement, so row budgeting is exact.
527    let strips: Vec<StripGeometry> = (0..geometry.len())
528        .map(|rank_index| compute_strip_geometry(&layout, rank_index, &geometry))
529        .collect();
530
531    let lanes = BackEdgeLanes::new(&layout);
532    let mut placed: Vec<PlacedRank> = Vec::new();
533    // Entry lanes above the first rank need an arrow row of their own.
534    let top_lanes = lanes.above(0).len() as i64;
535    let mut y = if top_lanes > 0 { top_lanes + 1 } else { 0 };
536    let rank_count = geometry.len();
537    for (rank_index, rank) in geometry.into_iter().enumerate() {
538        placed.push(PlacedRank {
539            cells: rank.cells,
540            centers: rank.centers,
541            y,
542        });
543        y += cell_height(node_style, metrics.height)
544            + lanes.below(rank_index).len() as i64
545            + gap_rows(&strips[rank_index], rank_index, rank_count)
546            + lanes.above(rank_index + 1).len() as i64;
547    }
548
549    let node_bounds = placed
550        .iter()
551        .flat_map(|rank| {
552            rank.cells
553                .iter()
554                .zip(&rank.centers)
555                .filter_map(|(cell, center)| match &cell.cell {
556                    GraphCell::Node { node_id } => Some(NodeBounds {
557                        node_id: node_id.clone(),
558                        x: center - cell.width / 2,
559                        y: rank.y,
560                        width: cell.width,
561                        height: cell_height(node_style, metrics.height),
562                    }),
563                    GraphCell::Virtual { .. } => None,
564                })
565        })
566        .collect();
567
568    let mut canvas = CharCanvas::new();
569    draw_nodes(
570        &mut canvas,
571        &placed,
572        &layout,
573        &transitions,
574        node_style,
575        metrics.height,
576    );
577    let labels = draw_segments(
578        &mut canvas,
579        &placed,
580        &strips,
581        &layout,
582        &transitions,
583        active_pair.as_deref(),
584        graph_width,
585        node_style,
586        metrics.height,
587        &lanes,
588    );
589    draw_back_edges(
590        &mut canvas,
591        &placed,
592        &layout,
593        &transitions,
594        graph_width,
595        node_style,
596        metrics.height,
597        &lanes,
598    );
599    // Labels go on last, once every line is on the canvas: placement can then
600    // guarantee no later stroke crosses through a label.
601    for label in labels {
602        draw_segment_label(&mut canvas, &label);
603    }
604    Some(RenderedGraph {
605        canvas,
606        node_bounds,
607    })
608}
609
610/// Render only the graph canvas for callers that do not need hit regions.
611pub fn render_graph_canvas(
612    view: &GraphView,
613    selected_step_index: i64,
614    at_latest_step: bool,
615    now_ms: i64,
616    node_style: GraphNodeStyle,
617) -> Option<CharCanvas> {
618    render_graph(
619        view,
620        selected_step_index,
621        at_latest_step,
622        now_ms,
623        node_style,
624    )
625    .map(|rendered| rendered.canvas)
626}
627
628/// Render the graph to plain text lines (parity with the TS renderer with
629/// colors disabled). The TS reference infers "at latest" from the index, so
630/// this entry point does the same.
631pub fn render_graph_lines(
632    view: &GraphView,
633    selected_step_index: i64,
634    now_ms: i64,
635    node_style: GraphNodeStyle,
636) -> Vec<String> {
637    let at_latest_step = selected_step_index >= view.state.steps.len() as i64 - 1;
638    match render_graph_canvas(
639        view,
640        selected_step_index,
641        at_latest_step,
642        now_ms,
643        node_style,
644    ) {
645        Some(canvas) => canvas.render_plain(),
646        None => Vec::new(),
647    }
648}
649
650/// Back edges route through dedicated lane rows: one below their source rank
651/// and one above their target rank.
652struct BackEdgeLanes {
653    edges: Vec<GraphEdge>,
654    rank_of_node: std::collections::HashMap<String, usize>,
655}
656
657impl BackEdgeLanes {
658    fn new(layout: &GraphLayout) -> Self {
659        Self {
660            edges: layout
661                .edges
662                .iter()
663                .filter(|edge| edge.is_back_edge)
664                .cloned()
665                .collect(),
666            rank_of_node: layout.rank_of_node.clone(),
667        }
668    }
669
670    fn below(&self, rank: usize) -> Vec<&GraphEdge> {
671        self.edges
672            .iter()
673            .filter(|edge| self.rank_of_node.get(&edge.from) == Some(&rank))
674            .collect()
675    }
676
677    fn above(&self, rank: usize) -> Vec<&GraphEdge> {
678        self.edges
679            .iter()
680            .filter(|edge| self.rank_of_node.get(&edge.to) == Some(&rank))
681            .collect()
682    }
683}
684
685/// The transition currently in flight, drawn in the active style.
686fn derive_pair_in_flight(
687    view: &GraphView,
688    visible_steps: &[StepRecord],
689    at_latest_step: bool,
690) -> Option<String> {
691    let state = view.state;
692    if at_latest_step {
693        if state.status == RunStatus::Running {
694            if let (Some(current), Some(last)) = (
695                state.current_node.as_deref().filter(|id| !id.is_empty()),
696                visible_steps.last(),
697            ) {
698                return Some(format!("{}->{current}", last.node_id));
699            }
700        }
701        return None;
702    }
703    if visible_steps.len() >= 2 {
704        let previous = &visible_steps[visible_steps.len() - 2];
705        let last = &visible_steps[visible_steps.len() - 1];
706        return Some(format!("{}->{}", previous.node_id, last.node_id));
707    }
708    None
709}
710
711/// Rows between rank r's cell rows and rank r+1's cell rows.
712fn gap_rows(strip: &StripGeometry, rank: usize, rank_count: usize) -> i64 {
713    if strip.segments.is_empty() {
714        return if rank < rank_count - 1 { 1 } else { 0 };
715    }
716    // Straight unlabeled strips need no track rows: one line row, one arrow row.
717    if strip.straight {
718        return 2;
719    }
720    // Labelled strips reserve one extra row below the tracks so labels that
721    // do not fit on their horizontal run always have a collision-free home.
722    2 + strip.track_count + if strip.has_labels { 1 } else { 0 }
723}
724
725/// Resolve a strip (all segments between rank r and rank r+1) to final pixel
726/// geometry: exit and entry columns, and a horizontal track row per segment.
727fn compute_strip_geometry(
728    layout: &GraphLayout,
729    rank: usize,
730    geometry: &[RankGeometry],
731) -> StripGeometry {
732    let strip: Vec<&GraphSegment> = layout
733        .segments
734        .iter()
735        .filter(|segment| segment.rank == rank)
736        .collect();
737    let (Some(top), Some(bottom)) = (geometry.get(rank), geometry.get(rank + 1)) else {
738        return StripGeometry {
739            segments: Vec::new(),
740            track_count: 1,
741            has_labels: false,
742            straight: true,
743        };
744    };
745    if strip.is_empty() {
746        return StripGeometry {
747            segments: Vec::new(),
748            track_count: 1,
749            has_labels: false,
750            straight: true,
751        };
752    }
753    let exit_offsets = fan_offsets(&strip, FanSide::From, top, bottom);
754    let entry_offsets = fan_offsets(&strip, FanSide::To, top, bottom);
755    struct Resolved {
756        edge_id: String,
757        label: Option<String>,
758        from_x: i64,
759        to_x: i64,
760        target_is_node: bool,
761    }
762    let mut resolved: Vec<Resolved> = strip
763        .iter()
764        .map(|segment| {
765            let from_x = top.centers[segment.from_cell]
766                + exit_offsets.get(&segment.edge_id).copied().unwrap_or(0);
767            let mut to_x = bottom.centers[segment.to_cell]
768                + entry_offsets.get(&segment.edge_id).copied().unwrap_or(0);
769            let target_is_node = bottom.cells[segment.to_cell].cell.is_node();
770            // A one-column jog reads as noise; draw it straight into the
771            // target, whose rendered cell is wide enough to absorb the
772            // offset. Virtual cells are exactly one column wide, so they
773            // must never be snapped.
774            if target_is_node && (to_x - from_x).abs() <= 1 {
775                to_x = from_x;
776            }
777            Resolved {
778                edge_id: segment.edge_id.clone(),
779                label: segment.label.clone(),
780                from_x,
781                to_x,
782                target_is_node,
783            }
784        })
785        .collect();
786
787    // First-fit track assignment over pixel spans; straight unlabeled
788    // segments draw a plain vertical line and need no track row.
789    resolved.sort_by_key(|segment| segment.from_x);
790    let mut segments: Vec<GeomSegment> = Vec::new();
791    let mut track_ranges: Vec<Vec<(i64, i64)>> = Vec::new();
792    for segment in resolved {
793        let mut track = 0i64;
794        if segment.from_x != segment.to_x || segment.label.is_some() {
795            let span = (
796                segment.from_x.min(segment.to_x),
797                segment.from_x.max(segment.to_x),
798            );
799            let found = track_ranges.iter().position(|ranges| {
800                ranges
801                    .iter()
802                    .all(|&(start, end)| span.1 < start || span.0 > end)
803            });
804            track = match found {
805                Some(index) => index as i64,
806                None => {
807                    track_ranges.push(Vec::new());
808                    track_ranges.len() as i64 - 1
809                }
810            };
811            track_ranges[track as usize].push(span);
812        }
813        segments.push(GeomSegment {
814            edge_id: segment.edge_id,
815            label: segment.label,
816            from_x: segment.from_x,
817            to_x: segment.to_x,
818            track,
819            target_is_node: segment.target_is_node,
820        });
821    }
822    StripGeometry {
823        track_count: (track_ranges.len() as i64).max(1),
824        has_labels: segments.iter().any(|segment| segment.label.is_some()),
825        straight: segments
826            .iter()
827            .all(|segment| segment.from_x == segment.to_x && segment.label.is_none()),
828        segments,
829    }
830}
831
832#[derive(Clone, Copy, PartialEq)]
833enum FanSide {
834    From,
835    To,
836}
837
838/// Fan columns for edges sharing a cell: segment i (ordered by the far
839/// end's x) gets column center - 2*(n-1-i), clamped to the cell, never
840/// right of center.
841fn fan_offsets(
842    strip: &[&GraphSegment],
843    side: FanSide,
844    top: &RankGeometry,
845    bottom: &RankGeometry,
846) -> std::collections::HashMap<String, i64> {
847    let (own_rank, far_rank) = match side {
848        FanSide::From => (top, bottom),
849        FanSide::To => (bottom, top),
850    };
851    let own_cell = |segment: &GraphSegment| match side {
852        FanSide::From => segment.from_cell,
853        FanSide::To => segment.to_cell,
854    };
855    let far_cell = |segment: &GraphSegment| match side {
856        FanSide::From => segment.to_cell,
857        FanSide::To => segment.from_cell,
858    };
859    let mut offsets = std::collections::HashMap::new();
860    // Preserve insertion order of groups for determinism.
861    let mut group_order: Vec<usize> = Vec::new();
862    let mut groups: std::collections::HashMap<usize, Vec<&GraphSegment>> =
863        std::collections::HashMap::new();
864    for segment in strip {
865        // Virtual cells are one column wide and always have one edge per side.
866        if own_rank.cells[own_cell(segment)].cell.is_node() {
867            let key = own_cell(segment);
868            if !groups.contains_key(&key) {
869                group_order.push(key);
870            }
871            groups.entry(key).or_default().push(segment);
872        }
873    }
874    for cell_index in group_order {
875        let group = &groups[&cell_index];
876        if group.len() < 2 {
877            continue;
878        }
879        let cell = &own_rank.cells[cell_index];
880        let max_offset = 1.max(cell.width / 2 - 1);
881        let mut ordered: Vec<&&GraphSegment> = group.iter().collect();
882        ordered.sort_by_key(|segment| far_rank.centers[far_cell(segment)]);
883        let count = ordered.len() as i64;
884        for (index, segment) in ordered.into_iter().enumerate() {
885            let offset = -2 * (count - 1 - index as i64);
886            offsets.insert(segment.edge_id.clone(), offset.max(-max_offset));
887        }
888    }
889    offsets
890}
891
892struct BoxChars {
893    tl: char,
894    tr: char,
895    ml: char,
896    mr: char,
897    bl: char,
898    br: char,
899    h: char,
900    v: char,
901}
902
903const BOX_LIGHT: BoxChars = BoxChars {
904    tl: '┌',
905    tr: '┐',
906    ml: '├',
907    mr: '┤',
908    bl: '└',
909    br: '┘',
910    h: '─',
911    v: '│',
912};
913
914const BOX_HEAVY: BoxChars = BoxChars {
915    tl: '┏',
916    tr: '┓',
917    ml: '┣',
918    mr: '┫',
919    bl: '┗',
920    br: '┛',
921    h: '━',
922    v: '┃',
923};
924
925fn draw_nodes(
926    canvas: &mut CharCanvas,
927    placed: &[PlacedRank],
928    layout: &GraphLayout,
929    transitions: &HashSet<String>,
930    node_style: GraphNodeStyle,
931    box_height: i64,
932) {
933    for rank in placed {
934        for (index, rendered) in rank.cells.iter().enumerate() {
935            let center = rank.centers[index];
936            match &rendered.cell {
937                GraphCell::Virtual { edge_id } => {
938                    let edge = layout
939                        .edges
940                        .iter()
941                        .find(|candidate| candidate.edge_id == *edge_id);
942                    let taken = edge.is_some_and(|edge| {
943                        transitions.contains(&format!("{}->{}", edge.from, edge.to))
944                    });
945                    // Pass-through cells span the full cell height so the
946                    // edge stays visually continuous across the rank row(s).
947                    canvas.vline(
948                        center,
949                        rank.y,
950                        rank.y + cell_height(node_style, box_height) - 1,
951                        if taken {
952                            CanvasStyle::Taken
953                        } else {
954                            CanvasStyle::Dim
955                        },
956                    );
957                }
958                GraphCell::Node { .. } => {
959                    let status = rendered.status.unwrap_or(NodeStatus::Queued);
960                    let start_x = center - rendered.width / 2;
961                    if node_style == GraphNodeStyle::Box {
962                        draw_node_box(canvas, start_x, rank.y, rendered, status);
963                    } else {
964                        if rendered.is_start {
965                            canvas.put(start_x - 2, rank.y, '▶', status.border_style());
966                        }
967                        canvas.put(start_x, rank.y, status.glyph(), status.border_style());
968                        canvas.text(
969                            start_x + 2,
970                            rank.y,
971                            &rendered.text,
972                            if status == NodeStatus::Queued {
973                                CanvasStyle::Dim
974                            } else {
975                                CanvasStyle::Plain
976                            },
977                        );
978                        if rendered.is_end {
979                            canvas.put(
980                                start_x + rendered.width + 1,
981                                rank.y,
982                                '■',
983                                status.border_style(),
984                            );
985                        }
986                    }
987                }
988            }
989        }
990    }
991}
992
993/// A bordered node cell; the active node gets a heavy border.
994fn draw_node_box(
995    canvas: &mut CharCanvas,
996    start_x: i64,
997    y: i64,
998    rendered: &RenderedCell,
999    status: NodeStatus,
1000) {
1001    let chars = if status.is_focused() {
1002        &BOX_HEAVY
1003    } else {
1004        &BOX_LIGHT
1005    };
1006    let border_style = status.border_style();
1007    let status_style = status.style();
1008    let type_style = node_type_style(&rendered.node_type, status.is_focused());
1009    let branch_style = if status.is_focused() {
1010        CanvasStyle::BranchFocus
1011    } else {
1012        CanvasStyle::Branch
1013    };
1014    let content_style = if status.is_focused() {
1015        CanvasStyle::NodeFocusText
1016    } else if status == NodeStatus::Queued {
1017        CanvasStyle::NodeDim
1018    } else {
1019        CanvasStyle::NodeText
1020    };
1021    let height = 7 + rendered.branch_lines.len() as i64;
1022    let inner_width = (rendered.width - 2) as usize;
1023    let right_x = start_x + rendered.width - 1;
1024    canvas.fill_rect(
1025        start_x + 1,
1026        y + 1,
1027        rendered.width - 2,
1028        1,
1029        CanvasStyle::NodeHeader,
1030    );
1031    canvas.fill_rect(
1032        start_x + 1,
1033        y + 3,
1034        rendered.width - 2,
1035        height - 4,
1036        content_style,
1037    );
1038    let horizontal: String = std::iter::repeat_n(chars.h, inner_width).collect();
1039
1040    canvas.text(
1041        start_x,
1042        y,
1043        &format!("{}{horizontal}{}", chars.tl, chars.tr),
1044        border_style,
1045    );
1046    canvas.text(start_x, y + 1, &chars.v.to_string(), border_style);
1047    canvas.text(right_x, y + 1, &chars.v.to_string(), border_style);
1048    canvas.text(
1049        start_x + 1,
1050        y + 1,
1051        &centered_text(&rendered.node_id, inner_width),
1052        CanvasStyle::NodeHeader,
1053    );
1054    canvas.text(
1055        start_x,
1056        y + 2,
1057        &format!("{}{horizontal}{}", chars.ml, chars.mr),
1058        border_style,
1059    );
1060
1061    let type_badge = fit_text(&node_type_badge(&rendered.node_type), inner_width - 2);
1062    let status_badge = fit_text(
1063        &format!("{} {}", status.glyph(), status.label()),
1064        inner_width - 2,
1065    );
1066    canvas.text(start_x, y + 3, &chars.v.to_string(), border_style);
1067    canvas.text(right_x, y + 3, &chars.v.to_string(), border_style);
1068    canvas.text(start_x + 2, y + 3, &type_badge, type_style);
1069    canvas.text(
1070        right_x - 1 - status_badge.chars().count() as i64,
1071        y + 3,
1072        &status_badge,
1073        status_style,
1074    );
1075
1076    let attempts = format!("↻ {}", rendered.attempts);
1077    let elapsed = format!("◷ {}", rendered.elapsed);
1078    canvas.text(start_x, y + 4, &chars.v.to_string(), border_style);
1079    canvas.text(right_x, y + 4, &chars.v.to_string(), border_style);
1080    canvas.text(start_x + 2, y + 4, &attempts, content_style);
1081    canvas.text(
1082        right_x - 1 - elapsed.chars().count() as i64,
1083        y + 4,
1084        &elapsed,
1085        content_style,
1086    );
1087
1088    for (index, branch) in rendered.branch_lines.iter().enumerate() {
1089        let row = y + 5 + index as i64;
1090        canvas.text(start_x, row, &chars.v.to_string(), border_style);
1091        canvas.text(right_x, row, &chars.v.to_string(), border_style);
1092        canvas.text(
1093            start_x + 2,
1094            row,
1095            &fit_text(branch, inner_width - 2),
1096            branch_style,
1097        );
1098    }
1099    let detail_row = y + 5 + rendered.branch_lines.len() as i64;
1100    canvas.text(start_x, detail_row, &chars.v.to_string(), border_style);
1101    canvas.text(right_x, detail_row, &chars.v.to_string(), border_style);
1102    if !rendered.detail.is_empty() {
1103        canvas.text(
1104            start_x + 2,
1105            detail_row,
1106            &fit_text(&format!("… {}", rendered.detail), inner_width - 2),
1107            content_style,
1108        );
1109    }
1110    canvas.text(
1111        start_x,
1112        y + height - 1,
1113        &format!("{}{horizontal}{}", chars.bl, chars.br),
1114        border_style,
1115    );
1116    if rendered.is_start {
1117        canvas.put(start_x - 2, y + 1, '▶', border_style);
1118    }
1119    if rendered.is_end {
1120        canvas.put(start_x + rendered.width + 1, y + 1, '■', border_style);
1121    }
1122}
1123
1124fn edge_style(
1125    pair_key: &str,
1126    transitions: &HashSet<String>,
1127    active_pair: Option<&str>,
1128) -> CanvasStyle {
1129    if active_pair == Some(pair_key) {
1130        return CanvasStyle::ActiveEdge;
1131    }
1132    if transitions.contains(pair_key) {
1133        return CanvasStyle::Taken;
1134    }
1135    CanvasStyle::Dim
1136}
1137
1138struct PendingLabel {
1139    text: String,
1140    style: CanvasStyle,
1141    from_x: i64,
1142    to_x: i64,
1143    track_y: i64,
1144    label_row: i64,
1145    graph_width: i64,
1146}
1147
1148#[allow(clippy::too_many_arguments)]
1149fn draw_segments(
1150    canvas: &mut CharCanvas,
1151    placed: &[PlacedRank],
1152    strips: &[StripGeometry],
1153    layout: &GraphLayout,
1154    transitions: &HashSet<String>,
1155    active_pair: Option<&str>,
1156    graph_width: i64,
1157    node_style: GraphNodeStyle,
1158    box_height: i64,
1159    lanes: &BackEdgeLanes,
1160) -> Vec<PendingLabel> {
1161    let mut labels = Vec::new();
1162    for rank in 0..placed.len().saturating_sub(1) {
1163        let strip = &strips[rank];
1164        if strip.segments.is_empty() {
1165            continue;
1166        }
1167        let top = &placed[rank];
1168        let bottom = &placed[rank + 1];
1169        // Forward lines start right below the source cell, cross any
1170        // back-edge lane rows (as ┼ crossings), run their strip tracks, then
1171        // cross the entry lanes to the arrow row directly above the target.
1172        let stub_top = top.y + cell_height(node_style, box_height);
1173        let strip_top = stub_top + lanes.below(rank).len() as i64;
1174        let arrow_y = bottom.y - 1;
1175        let strip_bottom = arrow_y - 1 - lanes.above(rank + 1).len() as i64;
1176        for segment in &strip.segments {
1177            let Some(edge) = layout
1178                .edges
1179                .iter()
1180                .find(|candidate| candidate.edge_id == segment.edge_id)
1181            else {
1182                continue;
1183            };
1184            let style = edge_style(
1185                &format!("{}->{}", edge.from, edge.to),
1186                transitions,
1187                active_pair,
1188            );
1189            let (from_x, to_x) = (segment.from_x, segment.to_x);
1190            let track_y = strip_top + segment.track;
1191            if from_x == to_x {
1192                canvas.vline(from_x, stub_top, arrow_y, style);
1193            } else {
1194                if track_y > stub_top {
1195                    canvas.vline(from_x, stub_top, track_y - 1, style);
1196                }
1197                canvas.put(
1198                    from_x,
1199                    track_y,
1200                    if to_x > from_x { '└' } else { '┘' },
1201                    style,
1202                );
1203                canvas.hline(track_y, from_x.min(to_x) + 1, from_x.max(to_x) - 1, style);
1204                canvas.put(to_x, track_y, if to_x > from_x { '┐' } else { '┌' }, style);
1205                if arrow_y > track_y {
1206                    canvas.vline(to_x, track_y + 1, arrow_y, style);
1207                }
1208            }
1209            if segment.target_is_node {
1210                canvas.put(to_x, arrow_y, '▼', style);
1211            }
1212            if let Some(label) = &segment.label {
1213                labels.push(PendingLabel {
1214                    text: label.clone(),
1215                    style,
1216                    from_x,
1217                    to_x,
1218                    track_y,
1219                    label_row: (strip_top + strip.track_count).min(strip_bottom),
1220                    graph_width,
1221                });
1222            }
1223        }
1224    }
1225    labels
1226}
1227
1228/// Place a branch label: first over the segment's own horizontal run, then
1229/// the strip's reserved label row beside the descending line (side facing
1230/// the graph center first), then beside the source corner.
1231fn draw_segment_label(canvas: &mut CharCanvas, label: &PendingLabel) {
1232    let padded = format!(" {} ", label.text);
1233    let padded_len = js_len(&padded);
1234    let text_len = js_len(&label.text);
1235    if label.from_x != label.to_x {
1236        let run_start = label.from_x.min(label.to_x) + 1;
1237        let run_end = label.from_x.max(label.to_x) - 1;
1238        let center = (run_start + run_end) / 2 - padded_len / 2;
1239        if run_end - run_start + 1 >= padded_len + 2
1240            && canvas.text_over_run(center, label.track_y, &padded, label.style)
1241        {
1242            return;
1243        }
1244    }
1245    let left = (label.to_x - text_len - 1, label.label_row);
1246    let right = (label.to_x + 2, label.label_row);
1247    let candidates = if label.to_x >= label.graph_width / 2 {
1248        [left, right]
1249    } else {
1250        [right, left]
1251    };
1252    for (x, y) in candidates {
1253        if canvas.text_if_empty(x, y, &label.text, label.style) {
1254            return;
1255        }
1256    }
1257    // Last resort: beside the source corner on the track row.
1258    canvas.text_if_empty(label.from_x + 2, label.track_y, &label.text, label.style);
1259}
1260
1261/// Each back edge leaves its source cell downward into its own lane row,
1262/// runs right to a private gutter column, climbs the gutter, and re-enters
1263/// through its target's entry lane and arrow row from above.
1264#[allow(clippy::too_many_arguments)]
1265fn draw_back_edges(
1266    canvas: &mut CharCanvas,
1267    placed: &[PlacedRank],
1268    layout: &GraphLayout,
1269    transitions: &HashSet<String>,
1270    graph_width: i64,
1271    node_style: GraphNodeStyle,
1272    box_height: i64,
1273    lanes: &BackEdgeLanes,
1274) {
1275    let mut gutter_x = graph_width + GUTTER_GAP;
1276    for edge in &lanes.edges {
1277        let (Some(&from_rank), Some(&to_rank)) = (
1278            layout.rank_of_node.get(&edge.from),
1279            layout.rank_of_node.get(&edge.to),
1280        ) else {
1281            continue;
1282        };
1283        let from = &placed[from_rank];
1284        let to = &placed[to_rank];
1285        let below = lanes.below(from_rank);
1286        let above = lanes.above(to_rank);
1287        let (Some(exit), Some(entry)) = (
1288            cell_anchor(from, &edge.from, &below, edge),
1289            cell_anchor(to, &edge.to, &above, edge),
1290        ) else {
1291            continue;
1292        };
1293        let style = if transitions.contains(&format!("{}->{}", edge.from, edge.to)) {
1294            CanvasStyle::Taken
1295        } else {
1296            CanvasStyle::Back
1297        };
1298        let exit_lane_y = from.y + cell_height(node_style, box_height) + exit.lane;
1299        let above_count = above.len() as i64;
1300        let arrow_y = to.y - 1;
1301        let entry_lane_y = arrow_y - above_count + entry.lane;
1302
1303        // Downward stub out of the source cell, then right along the exit lane.
1304        if exit_lane_y > from.y + cell_height(node_style, box_height) {
1305            canvas.vline(
1306                exit.x,
1307                from.y + cell_height(node_style, box_height),
1308                exit_lane_y - 1,
1309                style,
1310            );
1311        }
1312        canvas.put(exit.x, exit_lane_y, '└', style);
1313        canvas.hline(exit_lane_y, exit.x + 1, gutter_x - 1, style);
1314        canvas.put(gutter_x, exit_lane_y, '┘', style);
1315        // Up the gutter, then left along the entry lane into the target.
1316        canvas.put(gutter_x, entry_lane_y, '┐', style);
1317        if exit_lane_y - entry_lane_y > 1 {
1318            canvas.vline(gutter_x, entry_lane_y + 1, exit_lane_y - 1, style);
1319        }
1320        canvas.hline(entry_lane_y, entry.x + 1, gutter_x - 1, style);
1321        canvas.put(entry.x, entry_lane_y, '┌', style);
1322        if arrow_y - entry_lane_y > 1 {
1323            canvas.vline(entry.x, entry_lane_y + 1, arrow_y - 1, style);
1324        }
1325        canvas.put(entry.x, arrow_y, '▼', style);
1326        if let Some(label) = &edge.label {
1327            canvas.text(gutter_x + 2, entry_lane_y, label, style);
1328        }
1329        // Reserve horizontal room for this gutter and its label before the next.
1330        gutter_x += 2 + edge.label.as_deref().map_or(0, |label| js_len(label) + 1);
1331    }
1332}
1333
1334struct Anchor {
1335    x: i64,
1336    lane: i64,
1337}
1338
1339/// Where a back edge touches a node cell: offset right of center so the
1340/// stub can never collide with forward-edge lines at the center column,
1341/// clamped inside the cell.
1342fn cell_anchor(
1343    rank: &PlacedRank,
1344    node_id: &str,
1345    lane_edges: &[&GraphEdge],
1346    edge: &GraphEdge,
1347) -> Option<Anchor> {
1348    let index = rank
1349        .cells
1350        .iter()
1351        .position(|cell| matches!(&cell.cell, GraphCell::Node { node_id: id } if id == node_id))?;
1352    let lane = lane_edges
1353        .iter()
1354        .position(|candidate| candidate.edge_id == edge.edge_id)? as i64;
1355    let cell = &rank.cells[index];
1356    let center = rank.centers[index];
1357    let rightmost = center + cell.width / 2 - 1;
1358    Some(Anchor {
1359        x: (center + 2 + lane * 2).min(rightmost),
1360        lane,
1361    })
1362}