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