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