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