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