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