1use 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
17pub 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 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
572struct 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 straight: bool,
589}
590
591fn 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
614pub 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
635pub 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 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 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 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 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 for label in labels {
790 draw_segment_label(&mut canvas, &label);
791 }
792 Some(RenderedGraph {
793 canvas,
794 node_bounds,
795 })
796}
797
798pub 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
816pub 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
838struct 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
873fn 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
898fn 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 if strip.straight {
905 return 2;
906 }
907 2 + strip.track_count + if strip.has_labels { 1 } else { 0 }
910}
911
912fn 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 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 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
1028fn 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 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 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 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
1182fn 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 ¢ered_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 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
1419fn 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 canvas.text_if_empty(label.from_x + 2, label.track_y, &label.text, label.style);
1450}
1451
1452fn 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 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 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 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
1527fn 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}