1use crate::bundle::types::{
8 DefinitionSnapshot, EdgeDef, NodeOutcome, RunState, RunStatus, StepRecord,
9};
10use crate::canvas::{CanvasStyle, CharCanvas};
11use crate::format::{format_duration, parse_timestamp_ms, sanitize_text};
12use crate::layout::{layout_graph, GraphCell, GraphEdge, GraphLayout, GraphSegment};
13use serde_json::Value;
14use std::collections::HashSet;
15
16pub struct GraphView<'a> {
18 pub state: &'a RunState,
19 pub snapshot: Option<&'a DefinitionSnapshot>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum NodeStatus {
24 Completed,
25 Failed,
26 TimedOut,
27 Active,
28 ReplayFocus,
29 Waiting,
30 Queued,
31 Cancelled,
32}
33
34impl NodeStatus {
35 fn glyph(self) -> char {
36 match self {
37 NodeStatus::Completed => '✓',
38 NodeStatus::Failed => '✗',
39 NodeStatus::TimedOut => '×',
40 NodeStatus::Active => '◐',
41 NodeStatus::ReplayFocus => '◆',
42 NodeStatus::Waiting => '⏸',
43 NodeStatus::Cancelled => '~',
44 NodeStatus::Queued => '·',
45 }
46 }
47
48 fn label(self) -> &'static str {
49 match self {
50 NodeStatus::Completed => "completed",
51 NodeStatus::Failed => "failed",
52 NodeStatus::TimedOut => "timed out",
53 NodeStatus::Active => "running",
54 NodeStatus::ReplayFocus => "replay focus",
55 NodeStatus::Waiting => "waiting",
56 NodeStatus::Cancelled => "cancelled",
57 NodeStatus::Queued => "queued",
58 }
59 }
60
61 fn style(self) -> CanvasStyle {
62 match self {
63 NodeStatus::Completed => CanvasStyle::Ok,
64 NodeStatus::Failed => CanvasStyle::Fail,
65 NodeStatus::TimedOut => CanvasStyle::TimedOut,
66 NodeStatus::Active => CanvasStyle::Active,
67 NodeStatus::ReplayFocus => CanvasStyle::Replay,
68 NodeStatus::Waiting => CanvasStyle::Warn,
69 NodeStatus::Cancelled => CanvasStyle::Cancelled,
70 NodeStatus::Queued => CanvasStyle::NodeDim,
71 }
72 }
73
74 fn border_style(self) -> CanvasStyle {
75 match self {
76 NodeStatus::Completed => CanvasStyle::NodeBorderOk,
77 NodeStatus::Failed => CanvasStyle::NodeBorderFail,
78 NodeStatus::TimedOut => CanvasStyle::NodeBorderTimedOut,
79 NodeStatus::Active => CanvasStyle::NodeBorderActive,
80 NodeStatus::ReplayFocus => CanvasStyle::NodeBorderReplay,
81 NodeStatus::Waiting => CanvasStyle::NodeBorderWarn,
82 NodeStatus::Cancelled => CanvasStyle::NodeBorderCancelled,
83 NodeStatus::Queued => CanvasStyle::NodeBorderDim,
84 }
85 }
86
87 fn is_focused(self) -> bool {
88 matches!(self, NodeStatus::Active | NodeStatus::ReplayFocus)
89 }
90}
91
92const CELL_GAP: i64 = 6;
93const GUTTER_GAP: i64 = 2;
94const GRAPH_SIDE_MARGIN: i64 = 2;
95const CARD_MIN_CONTENT_WIDTH: i64 = 28;
96const CARD_DYNAMIC_RESERVE: &str = "↻ 100 ◷ 9999d 23h 59m 59s";
97
98fn node_type_glyph(node_type: &str, action_execution: Option<&str>) -> char {
99 match (node_type, action_execution) {
100 ("agent", _) => '●',
101 ("compute", _) => 'ƒ',
102 ("notify", _) => '!',
103 ("action", Some("shell")) => '$',
104 ("action", _) => '*',
105 ("checkpoint", _) => '◆',
106 _ => '?',
107 }
108}
109
110fn node_type_style(node_type: &str, focused: bool) -> CanvasStyle {
111 match (node_type, focused) {
112 ("agent", false) => CanvasStyle::Agent,
113 ("agent", true) => CanvasStyle::AgentFocus,
114 ("compute", false) => CanvasStyle::Compute,
115 ("compute", true) => CanvasStyle::ComputeFocus,
116 ("notify", false) => CanvasStyle::Action,
117 ("notify", true) => CanvasStyle::ActionFocus,
118 ("action", false) => CanvasStyle::Action,
119 ("action", true) => CanvasStyle::ActionFocus,
120 ("checkpoint", false) => CanvasStyle::Checkpoint,
121 ("checkpoint", true) => CanvasStyle::CheckpointFocus,
122 (_, false) => CanvasStyle::NodeDim,
123 (_, true) => CanvasStyle::NodeFocusText,
124 }
125}
126
127fn node_type_badge(node_type: &str, action_execution: Option<&str>) -> String {
128 format!(
129 "{} {node_type}",
130 node_type_glyph(node_type, action_execution)
131 )
132}
133
134fn fit_text(text: &str, width: usize) -> String {
135 let chars: Vec<char> = text.chars().collect();
136 if chars.len() <= width {
137 return text.to_string();
138 }
139 if width <= 1 {
140 return chars.into_iter().take(width).collect();
141 }
142 format!("{}…", chars.into_iter().take(width - 1).collect::<String>())
143}
144
145fn centered_text(text: &str, width: usize) -> String {
146 let fitted = fit_text(text, width);
147 let left = width.saturating_sub(fitted.chars().count()) / 2;
148 format!("{}{fitted}", " ".repeat(left))
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum GraphNodeStyle {
153 Line,
154 Box,
155}
156
157#[derive(Debug, Clone, Copy)]
158struct CardMetrics {
159 width: i64,
160 height: i64,
161 branch_rows: usize,
162}
163
164fn cell_height(node_style: GraphNodeStyle, box_height: i64) -> i64 {
165 match node_style {
166 GraphNodeStyle::Box => box_height,
167 GraphNodeStyle::Line => 1,
168 }
169}
170
171fn js_len(text: &str) -> i64 {
173 text.encode_utf16().count() as i64
174}
175
176fn latest_visible_attempt<'a>(steps: &'a [StepRecord], node_id: &str) -> Option<&'a StepRecord> {
177 steps.iter().rev().find(|step| step.node_id == node_id)
178}
179
180fn derive_node_status(
181 view: &GraphView,
182 node_id: &str,
183 visible_steps: &[StepRecord],
184 at_latest_step: bool,
185) -> NodeStatus {
186 let state = view.state;
187 if at_latest_step && state.current_node.as_deref() == Some(node_id) {
188 return NodeStatus::Active;
189 }
190 if at_latest_step && state.waiting_on.as_deref() == Some(node_id) {
191 return NodeStatus::Waiting;
192 }
193 let Some(attempt) = latest_visible_attempt(visible_steps, node_id) else {
194 return NodeStatus::Queued;
195 };
196 if !at_latest_step && visible_steps.last().map(|step| step.node_id.as_str()) == Some(node_id) {
198 return NodeStatus::ReplayFocus;
199 }
200 match attempt.outcome {
201 NodeOutcome::Ok => NodeStatus::Completed,
202 NodeOutcome::TimedOut => NodeStatus::TimedOut,
203 NodeOutcome::Cancelled => NodeStatus::Cancelled,
204 NodeOutcome::Failed => NodeStatus::Failed,
205 }
206}
207
208fn node_branch_labels(view: &GraphView, node_id: &str) -> Vec<String> {
209 view.snapshot
210 .map(|snapshot| {
211 snapshot
212 .edges
213 .iter()
214 .flat_map(|edge| match edge {
215 EdgeDef::Switch { from, switch } if from == node_id => switch
216 .cases
217 .keys()
218 .map(|label| sanitize_text(label))
219 .collect::<Vec<_>>(),
220 _ => Vec::new(),
221 })
222 .collect()
223 })
224 .unwrap_or_default()
225}
226
227fn hierarchical_node_label(node_id: &str, node: Option<&Value>) -> String {
228 let Some(node) = node else {
229 return sanitize_text(node_id);
230 };
231 let Some(mount_path) = node.get("mountPath").and_then(Value::as_array) else {
232 return sanitize_text(node_id);
233 };
234 let path = mount_path
235 .iter()
236 .filter_map(Value::as_str)
237 .map(sanitize_text)
238 .collect::<Vec<_>>()
239 .join(" › ");
240 let Some(local_node_id) = node.get("localNodeId").and_then(Value::as_str) else {
241 return sanitize_text(node_id);
242 };
243 match node.get("includeTransition").and_then(Value::as_str) {
244 Some("entry") => format!("{path} · enter"),
245 Some("exit") => format!("{path} · {} exit", sanitize_text(local_node_id)),
246 _ => format!("{path} › {}", sanitize_text(local_node_id)),
247 }
248}
249
250fn card_metrics(view: &GraphView) -> CardMetrics {
251 let Some(snapshot) = view.snapshot else {
252 return CardMetrics {
253 width: CARD_MIN_CONTENT_WIDTH + 4,
254 height: 7,
255 branch_rows: 0,
256 };
257 };
258 let mut content_width = CARD_MIN_CONTENT_WIDTH.max(js_len(CARD_DYNAMIC_RESERVE));
259 let mut branch_rows = 0usize;
260 for status in [
261 NodeStatus::Completed,
262 NodeStatus::Failed,
263 NodeStatus::TimedOut,
264 NodeStatus::Active,
265 NodeStatus::ReplayFocus,
266 NodeStatus::Waiting,
267 NodeStatus::Queued,
268 NodeStatus::Cancelled,
269 ] {
270 content_width = content_width.max(js_len(&format!(
271 "{} {} {}",
272 node_type_badge("checkpoint", None),
273 status.glyph(),
274 status.label()
275 )));
276 }
277 for (node_id, node) in &snapshot.nodes {
278 content_width = content_width.max(js_len(&hierarchical_node_label(node_id, Some(node))));
279 if let Some(node_type) = node.get("nodeType").and_then(Value::as_str) {
280 let action_execution = node.get("actionExecution").and_then(Value::as_str);
281 content_width =
282 content_width.max(js_len(&node_type_badge(node_type, action_execution)));
283 }
284 let labels = node_branch_labels(view, node_id);
285 branch_rows = branch_rows.max(labels.len());
286 for label in labels {
287 content_width = content_width.max(js_len(&format!("◇ {label}")));
288 }
289 }
290 CardMetrics {
291 width: content_width + 4,
292 height: 7 + branch_rows as i64,
293 branch_rows,
294 }
295}
296
297struct RenderedCell {
298 cell: GraphCell,
299 text: String,
300 node_id: String,
301 node_type: String,
302 type_badge: String,
303 status: Option<NodeStatus>,
304 attempts: usize,
305 elapsed: String,
306 detail: String,
307 branch_lines: Vec<String>,
308 is_start: bool,
309 is_end: bool,
310 width: i64,
311}
312
313fn render_cell_text(
314 view: &GraphView,
315 cell: &GraphCell,
316 visible_steps: &[StepRecord],
317 at_latest_step: bool,
318 now_ms: i64,
319 node_style: GraphNodeStyle,
320 metrics: CardMetrics,
321) -> RenderedCell {
322 let GraphCell::Node { node_id } = cell else {
323 return RenderedCell {
324 cell: cell.clone(),
325 text: String::new(),
326 node_id: String::new(),
327 node_type: String::new(),
328 type_badge: String::new(),
329 status: None,
330 attempts: 0,
331 elapsed: String::new(),
332 detail: String::new(),
333 branch_lines: Vec::new(),
334 is_start: false,
335 is_end: false,
336 width: 1,
337 };
338 };
339 let state = view.state;
340 let status = derive_node_status(view, node_id, visible_steps, at_latest_step);
341 let node = view
342 .snapshot
343 .and_then(|snapshot| snapshot.nodes.get(node_id));
344 let node_type = view
345 .snapshot
346 .and_then(|snapshot| snapshot.node_type(node_id))
347 .unwrap_or("?");
348 let action_execution = view
349 .snapshot
350 .and_then(|snapshot| snapshot.node_action_execution(node_id));
351 let attempt = latest_visible_attempt(visible_steps, node_id);
352 let attempts = visible_steps
353 .iter()
354 .filter(|step| step.node_id == *node_id)
355 .count();
356 let labels = node_branch_labels(view, node_id);
357 let outgoing = view.snapshot.map_or(0, |snapshot| {
358 snapshot
359 .edges
360 .iter()
361 .filter_map(|edge| match edge {
362 EdgeDef::Simple { from, .. } if from == node_id => Some(1),
363 EdgeDef::Switch { from, switch } if from == node_id => Some(switch.cases.len()),
364 _ => None,
365 })
366 .sum::<usize>()
367 });
368 let is_start = view
369 .snapshot
370 .is_some_and(|snapshot| snapshot.start_at == *node_id);
371 let is_end = outgoing == 0;
372 let elapsed = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
373 let started_at = state
374 .current_node_started_at
375 .as_deref()
376 .and_then(parse_timestamp_ms)
377 .unwrap_or(now_ms);
378 format_duration(now_ms - started_at)
379 } else if let Some(attempt) = attempt {
380 let duration_ms = parse_timestamp_ms(&attempt.finished_at).unwrap_or(0)
381 - parse_timestamp_ms(&attempt.started_at).unwrap_or(0);
382 format_duration(duration_ms)
383 } else {
384 "—".to_string()
385 };
386 let detail = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
387 state
388 .status_detail
389 .as_deref()
390 .map(sanitize_text)
391 .unwrap_or_default()
392 } else {
393 node.and_then(|node| {
394 node.get("statusDetail")
395 .or_else(|| node.get("summary"))
396 .and_then(Value::as_str)
397 })
398 .map(sanitize_text)
399 .unwrap_or_default()
400 };
401 let mut branch_lines: Vec<String> = labels
402 .into_iter()
403 .map(|label| format!("◇ {label}"))
404 .collect();
405 branch_lines.resize(metrics.branch_rows, String::new());
406 let count = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
407 attempts.max(1)
408 } else {
409 attempts
410 };
411 let timing = if attempt.is_some() || count > 0 {
412 format!(
413 "{count} attempt{} · {elapsed}",
414 if count == 1 { "" } else { "s" }
415 )
416 } else {
417 "not visited".to_string()
418 };
419 let display_node_id = hierarchical_node_label(node_id, node);
420 let text = format!("{display_node_id} [{node_type}] {timing}");
421 RenderedCell {
422 cell: cell.clone(),
423 text: text.clone(),
424 node_id: display_node_id,
425 node_type: node_type.to_string(),
426 type_badge: if node.is_some() {
427 node_type_badge(node_type, action_execution)
428 } else {
429 "? unknown".to_string()
430 },
431 status: Some(status),
432 attempts: count,
433 elapsed,
434 detail,
435 branch_lines,
436 is_start,
437 is_end,
438 width: match node_style {
439 GraphNodeStyle::Box => metrics.width,
440 GraphNodeStyle::Line => js_len(&text) + 2,
441 },
442 }
443}
444
445struct RankGeometry {
446 cells: Vec<RenderedCell>,
447 centers: Vec<i64>,
448}
449
450struct PlacedRank {
451 cells: Vec<RenderedCell>,
452 centers: Vec<i64>,
453 y: i64,
454}
455
456struct GeomSegment {
458 edge_id: String,
459 label: Option<String>,
460 from_x: i64,
461 to_x: i64,
462 track: i64,
463 target_is_node: bool,
464}
465
466struct StripGeometry {
467 segments: Vec<GeomSegment>,
468 track_count: i64,
469 has_labels: bool,
470 straight: bool,
472}
473
474fn taken_transitions(visible_steps: &[StepRecord]) -> HashSet<String> {
476 visible_steps
477 .windows(2)
478 .map(|pair| format!("{}->{}", pair[0].node_id, pair[1].node_id))
479 .collect()
480}
481
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct NodeBounds {
484 pub node_id: String,
485 pub x: i64,
486 pub y: i64,
487 pub width: i64,
488 pub height: i64,
489}
490
491pub struct RenderedGraph {
492 pub canvas: CharCanvas,
493 pub node_bounds: Vec<NodeBounds>,
494}
495
496pub fn render_graph(
500 view: &GraphView,
501 selected_step_index: i64,
502 at_latest_step: bool,
503 now_ms: i64,
504 node_style: GraphNodeStyle,
505) -> Option<RenderedGraph> {
506 let snapshot = view.snapshot?;
507 let metrics = card_metrics(view);
508 let layout = layout_graph(snapshot);
509 let steps = &view.state.steps;
510 let bounded_index = selected_step_index.max(-1).min(steps.len() as i64 - 1);
511 let visible_steps = &steps[0..(bounded_index + 1) as usize];
512 let transitions = taken_transitions(visible_steps);
513 let active_pair = derive_pair_in_flight(view, visible_steps, at_latest_step);
514
515 let rendered: Vec<Vec<RenderedCell>> = layout
516 .ranks
517 .iter()
518 .map(|rank| {
519 rank.iter()
520 .map(|cell| {
521 render_cell_text(
522 view,
523 cell,
524 visible_steps,
525 at_latest_step,
526 now_ms,
527 node_style,
528 metrics,
529 )
530 })
531 .collect()
532 })
533 .collect();
534
535 let rank_widths: Vec<i64> = rendered
538 .iter()
539 .map(|cells| {
540 cells.iter().map(|cell| cell.width).sum::<i64>()
541 + 0.max(cells.len() as i64 - 1) * CELL_GAP
542 })
543 .collect();
544 let graph_width = rank_widths.iter().copied().max().unwrap_or(0).max(0) + GRAPH_SIDE_MARGIN * 2;
545 let geometry: Vec<RankGeometry> = rendered
546 .into_iter()
547 .enumerate()
548 .map(|(rank_index, cells)| {
549 let mut centers = Vec::with_capacity(cells.len());
550 let mut x = (graph_width - rank_widths[rank_index]) / 2;
551 for cell in &cells {
552 centers.push(if cells.len() == 1 {
555 graph_width / 2
556 } else {
557 x + cell.width / 2
558 });
559 x += cell.width + CELL_GAP;
560 }
561 RankGeometry { cells, centers }
562 })
563 .collect();
564
565 let strips: Vec<StripGeometry> = (0..geometry.len())
568 .map(|rank_index| compute_strip_geometry(&layout, rank_index, &geometry))
569 .collect();
570
571 let lanes = BackEdgeLanes::new(&layout);
572 let mut placed: Vec<PlacedRank> = Vec::new();
573 let top_lanes = lanes.above(0).len() as i64;
575 let mut y = if top_lanes > 0 { top_lanes + 1 } else { 0 };
576 let rank_count = geometry.len();
577 for (rank_index, rank) in geometry.into_iter().enumerate() {
578 placed.push(PlacedRank {
579 cells: rank.cells,
580 centers: rank.centers,
581 y,
582 });
583 y += cell_height(node_style, metrics.height)
584 + lanes.below(rank_index).len() as i64
585 + gap_rows(&strips[rank_index], rank_index, rank_count)
586 + lanes.above(rank_index + 1).len() as i64;
587 }
588
589 let node_bounds = placed
590 .iter()
591 .flat_map(|rank| {
592 rank.cells
593 .iter()
594 .zip(&rank.centers)
595 .filter_map(|(cell, center)| match &cell.cell {
596 GraphCell::Node { node_id } => Some(NodeBounds {
597 node_id: node_id.clone(),
598 x: center - cell.width / 2,
599 y: rank.y,
600 width: cell.width,
601 height: cell_height(node_style, metrics.height),
602 }),
603 GraphCell::Virtual { .. } => None,
604 })
605 })
606 .collect();
607
608 let mut canvas = CharCanvas::new();
609 draw_nodes(
610 &mut canvas,
611 &placed,
612 &layout,
613 &transitions,
614 node_style,
615 metrics.height,
616 );
617 let labels = draw_segments(
618 &mut canvas,
619 &placed,
620 &strips,
621 &layout,
622 &transitions,
623 active_pair.as_deref(),
624 graph_width,
625 node_style,
626 metrics.height,
627 &lanes,
628 );
629 draw_back_edges(
630 &mut canvas,
631 &placed,
632 &layout,
633 &transitions,
634 graph_width,
635 node_style,
636 metrics.height,
637 &lanes,
638 );
639 for label in labels {
642 draw_segment_label(&mut canvas, &label);
643 }
644 Some(RenderedGraph {
645 canvas,
646 node_bounds,
647 })
648}
649
650pub fn render_graph_canvas(
652 view: &GraphView,
653 selected_step_index: i64,
654 at_latest_step: bool,
655 now_ms: i64,
656 node_style: GraphNodeStyle,
657) -> Option<CharCanvas> {
658 render_graph(
659 view,
660 selected_step_index,
661 at_latest_step,
662 now_ms,
663 node_style,
664 )
665 .map(|rendered| rendered.canvas)
666}
667
668pub fn render_graph_lines(
672 view: &GraphView,
673 selected_step_index: i64,
674 now_ms: i64,
675 node_style: GraphNodeStyle,
676) -> Vec<String> {
677 let at_latest_step = selected_step_index >= view.state.steps.len() as i64 - 1;
678 match render_graph_canvas(
679 view,
680 selected_step_index,
681 at_latest_step,
682 now_ms,
683 node_style,
684 ) {
685 Some(canvas) => canvas.render_plain(),
686 None => Vec::new(),
687 }
688}
689
690struct BackEdgeLanes {
693 edges: Vec<GraphEdge>,
694 rank_of_node: std::collections::HashMap<String, usize>,
695}
696
697impl BackEdgeLanes {
698 fn new(layout: &GraphLayout) -> Self {
699 Self {
700 edges: layout
701 .edges
702 .iter()
703 .filter(|edge| edge.is_back_edge)
704 .cloned()
705 .collect(),
706 rank_of_node: layout.rank_of_node.clone(),
707 }
708 }
709
710 fn below(&self, rank: usize) -> Vec<&GraphEdge> {
711 self.edges
712 .iter()
713 .filter(|edge| self.rank_of_node.get(&edge.from) == Some(&rank))
714 .collect()
715 }
716
717 fn above(&self, rank: usize) -> Vec<&GraphEdge> {
718 self.edges
719 .iter()
720 .filter(|edge| self.rank_of_node.get(&edge.to) == Some(&rank))
721 .collect()
722 }
723}
724
725fn derive_pair_in_flight(
727 view: &GraphView,
728 visible_steps: &[StepRecord],
729 at_latest_step: bool,
730) -> Option<String> {
731 let state = view.state;
732 if at_latest_step {
733 if state.status == RunStatus::Running {
734 if let (Some(current), Some(last)) = (
735 state.current_node.as_deref().filter(|id| !id.is_empty()),
736 visible_steps.last(),
737 ) {
738 return Some(format!("{}->{current}", last.node_id));
739 }
740 }
741 return None;
742 }
743 if visible_steps.len() >= 2 {
744 let previous = &visible_steps[visible_steps.len() - 2];
745 let last = &visible_steps[visible_steps.len() - 1];
746 return Some(format!("{}->{}", previous.node_id, last.node_id));
747 }
748 None
749}
750
751fn gap_rows(strip: &StripGeometry, rank: usize, rank_count: usize) -> i64 {
753 if strip.segments.is_empty() {
754 return if rank < rank_count - 1 { 1 } else { 0 };
755 }
756 if strip.straight {
758 return 2;
759 }
760 2 + strip.track_count + if strip.has_labels { 1 } else { 0 }
763}
764
765fn compute_strip_geometry(
768 layout: &GraphLayout,
769 rank: usize,
770 geometry: &[RankGeometry],
771) -> StripGeometry {
772 let strip: Vec<&GraphSegment> = layout
773 .segments
774 .iter()
775 .filter(|segment| segment.rank == rank)
776 .collect();
777 let (Some(top), Some(bottom)) = (geometry.get(rank), geometry.get(rank + 1)) else {
778 return StripGeometry {
779 segments: Vec::new(),
780 track_count: 1,
781 has_labels: false,
782 straight: true,
783 };
784 };
785 if strip.is_empty() {
786 return StripGeometry {
787 segments: Vec::new(),
788 track_count: 1,
789 has_labels: false,
790 straight: true,
791 };
792 }
793 let exit_offsets = fan_offsets(&strip, FanSide::From, top, bottom);
794 let entry_offsets = fan_offsets(&strip, FanSide::To, top, bottom);
795 struct Resolved {
796 edge_id: String,
797 label: Option<String>,
798 from_x: i64,
799 to_x: i64,
800 target_is_node: bool,
801 }
802 let mut resolved: Vec<Resolved> = strip
803 .iter()
804 .map(|segment| {
805 let from_x = top.centers[segment.from_cell]
806 + exit_offsets.get(&segment.edge_id).copied().unwrap_or(0);
807 let mut to_x = bottom.centers[segment.to_cell]
808 + entry_offsets.get(&segment.edge_id).copied().unwrap_or(0);
809 let target_is_node = bottom.cells[segment.to_cell].cell.is_node();
810 if target_is_node && (to_x - from_x).abs() <= 1 {
815 to_x = from_x;
816 }
817 Resolved {
818 edge_id: segment.edge_id.clone(),
819 label: segment.label.clone(),
820 from_x,
821 to_x,
822 target_is_node,
823 }
824 })
825 .collect();
826
827 resolved.sort_by_key(|segment| segment.from_x);
830 let mut segments: Vec<GeomSegment> = Vec::new();
831 let mut track_ranges: Vec<Vec<(i64, i64)>> = Vec::new();
832 for segment in resolved {
833 let mut track = 0i64;
834 if segment.from_x != segment.to_x || segment.label.is_some() {
835 let span = (
836 segment.from_x.min(segment.to_x),
837 segment.from_x.max(segment.to_x),
838 );
839 let found = track_ranges.iter().position(|ranges| {
840 ranges
841 .iter()
842 .all(|&(start, end)| span.1 < start || span.0 > end)
843 });
844 track = match found {
845 Some(index) => index as i64,
846 None => {
847 track_ranges.push(Vec::new());
848 track_ranges.len() as i64 - 1
849 }
850 };
851 track_ranges[track as usize].push(span);
852 }
853 segments.push(GeomSegment {
854 edge_id: segment.edge_id,
855 label: segment.label,
856 from_x: segment.from_x,
857 to_x: segment.to_x,
858 track,
859 target_is_node: segment.target_is_node,
860 });
861 }
862 StripGeometry {
863 track_count: (track_ranges.len() as i64).max(1),
864 has_labels: segments.iter().any(|segment| segment.label.is_some()),
865 straight: segments
866 .iter()
867 .all(|segment| segment.from_x == segment.to_x && segment.label.is_none()),
868 segments,
869 }
870}
871
872#[derive(Clone, Copy, PartialEq)]
873enum FanSide {
874 From,
875 To,
876}
877
878fn fan_offsets(
882 strip: &[&GraphSegment],
883 side: FanSide,
884 top: &RankGeometry,
885 bottom: &RankGeometry,
886) -> std::collections::HashMap<String, i64> {
887 let (own_rank, far_rank) = match side {
888 FanSide::From => (top, bottom),
889 FanSide::To => (bottom, top),
890 };
891 let own_cell = |segment: &GraphSegment| match side {
892 FanSide::From => segment.from_cell,
893 FanSide::To => segment.to_cell,
894 };
895 let far_cell = |segment: &GraphSegment| match side {
896 FanSide::From => segment.to_cell,
897 FanSide::To => segment.from_cell,
898 };
899 let mut offsets = std::collections::HashMap::new();
900 let mut group_order: Vec<usize> = Vec::new();
902 let mut groups: std::collections::HashMap<usize, Vec<&GraphSegment>> =
903 std::collections::HashMap::new();
904 for segment in strip {
905 if own_rank.cells[own_cell(segment)].cell.is_node() {
907 let key = own_cell(segment);
908 if !groups.contains_key(&key) {
909 group_order.push(key);
910 }
911 groups.entry(key).or_default().push(segment);
912 }
913 }
914 for cell_index in group_order {
915 let group = &groups[&cell_index];
916 if group.len() < 2 {
917 continue;
918 }
919 let cell = &own_rank.cells[cell_index];
920 let max_offset = 1.max(cell.width / 2 - 1);
921 let mut ordered: Vec<&&GraphSegment> = group.iter().collect();
922 ordered.sort_by_key(|segment| far_rank.centers[far_cell(segment)]);
923 let count = ordered.len() as i64;
924 for (index, segment) in ordered.into_iter().enumerate() {
925 let offset = -2 * (count - 1 - index as i64);
926 offsets.insert(segment.edge_id.clone(), offset.max(-max_offset));
927 }
928 }
929 offsets
930}
931
932struct BoxChars {
933 tl: char,
934 tr: char,
935 ml: char,
936 mr: char,
937 bl: char,
938 br: char,
939 h: char,
940 v: char,
941}
942
943const BOX_LIGHT: BoxChars = BoxChars {
944 tl: '┌',
945 tr: '┐',
946 ml: '├',
947 mr: '┤',
948 bl: '└',
949 br: '┘',
950 h: '─',
951 v: '│',
952};
953
954const BOX_HEAVY: BoxChars = BoxChars {
955 tl: '┏',
956 tr: '┓',
957 ml: '┣',
958 mr: '┫',
959 bl: '┗',
960 br: '┛',
961 h: '━',
962 v: '┃',
963};
964
965fn draw_nodes(
966 canvas: &mut CharCanvas,
967 placed: &[PlacedRank],
968 layout: &GraphLayout,
969 transitions: &HashSet<String>,
970 node_style: GraphNodeStyle,
971 box_height: i64,
972) {
973 for rank in placed {
974 for (index, rendered) in rank.cells.iter().enumerate() {
975 let center = rank.centers[index];
976 match &rendered.cell {
977 GraphCell::Virtual { edge_id } => {
978 let edge = layout
979 .edges
980 .iter()
981 .find(|candidate| candidate.edge_id == *edge_id);
982 let taken = edge.is_some_and(|edge| {
983 transitions.contains(&format!("{}->{}", edge.from, edge.to))
984 });
985 canvas.vline(
988 center,
989 rank.y,
990 rank.y + cell_height(node_style, box_height) - 1,
991 if taken {
992 CanvasStyle::Taken
993 } else {
994 CanvasStyle::Dim
995 },
996 );
997 }
998 GraphCell::Node { .. } => {
999 let status = rendered.status.unwrap_or(NodeStatus::Queued);
1000 let start_x = center - rendered.width / 2;
1001 if node_style == GraphNodeStyle::Box {
1002 draw_node_box(canvas, start_x, rank.y, rendered, status);
1003 } else {
1004 if rendered.is_start {
1005 canvas.put(start_x - 2, rank.y, '▶', status.border_style());
1006 }
1007 canvas.put(start_x, rank.y, status.glyph(), status.border_style());
1008 canvas.text(
1009 start_x + 2,
1010 rank.y,
1011 &rendered.text,
1012 if status == NodeStatus::Queued {
1013 CanvasStyle::Dim
1014 } else {
1015 CanvasStyle::Plain
1016 },
1017 );
1018 if rendered.is_end {
1019 canvas.put(
1020 start_x + rendered.width + 1,
1021 rank.y,
1022 '■',
1023 status.border_style(),
1024 );
1025 }
1026 }
1027 }
1028 }
1029 }
1030 }
1031}
1032
1033fn draw_node_box(
1035 canvas: &mut CharCanvas,
1036 start_x: i64,
1037 y: i64,
1038 rendered: &RenderedCell,
1039 status: NodeStatus,
1040) {
1041 let chars = if status.is_focused() {
1042 &BOX_HEAVY
1043 } else {
1044 &BOX_LIGHT
1045 };
1046 let border_style = status.border_style();
1047 let status_style = status.style();
1048 let type_style = node_type_style(&rendered.node_type, status.is_focused());
1049 let branch_style = if status.is_focused() {
1050 CanvasStyle::BranchFocus
1051 } else {
1052 CanvasStyle::Branch
1053 };
1054 let content_style = if status.is_focused() {
1055 CanvasStyle::NodeFocusText
1056 } else if status == NodeStatus::Queued {
1057 CanvasStyle::NodeDim
1058 } else {
1059 CanvasStyle::NodeText
1060 };
1061 let height = 7 + rendered.branch_lines.len() as i64;
1062 let inner_width = (rendered.width - 2) as usize;
1063 let right_x = start_x + rendered.width - 1;
1064 canvas.fill_rect(
1065 start_x + 1,
1066 y + 1,
1067 rendered.width - 2,
1068 1,
1069 CanvasStyle::NodeHeader,
1070 );
1071 canvas.fill_rect(
1072 start_x + 1,
1073 y + 3,
1074 rendered.width - 2,
1075 height - 4,
1076 content_style,
1077 );
1078 let horizontal: String = std::iter::repeat_n(chars.h, inner_width).collect();
1079
1080 canvas.text(
1081 start_x,
1082 y,
1083 &format!("{}{horizontal}{}", chars.tl, chars.tr),
1084 border_style,
1085 );
1086 canvas.text(start_x, y + 1, &chars.v.to_string(), border_style);
1087 canvas.text(right_x, y + 1, &chars.v.to_string(), border_style);
1088 canvas.text(
1089 start_x + 1,
1090 y + 1,
1091 ¢ered_text(&rendered.node_id, inner_width),
1092 CanvasStyle::NodeHeader,
1093 );
1094 canvas.text(
1095 start_x,
1096 y + 2,
1097 &format!("{}{horizontal}{}", chars.ml, chars.mr),
1098 border_style,
1099 );
1100
1101 let type_badge = fit_text(&rendered.type_badge, inner_width - 2);
1102 let status_badge = fit_text(
1103 &format!("{} {}", status.glyph(), status.label()),
1104 inner_width - 2,
1105 );
1106 canvas.text(start_x, y + 3, &chars.v.to_string(), border_style);
1107 canvas.text(right_x, y + 3, &chars.v.to_string(), border_style);
1108 canvas.text(start_x + 2, y + 3, &type_badge, type_style);
1109 canvas.text(
1110 right_x - 1 - status_badge.chars().count() as i64,
1111 y + 3,
1112 &status_badge,
1113 status_style,
1114 );
1115
1116 let attempts = format!("↻ {}", rendered.attempts);
1117 let elapsed = format!("◷ {}", rendered.elapsed);
1118 canvas.text(start_x, y + 4, &chars.v.to_string(), border_style);
1119 canvas.text(right_x, y + 4, &chars.v.to_string(), border_style);
1120 canvas.text(start_x + 2, y + 4, &attempts, content_style);
1121 canvas.text(
1122 right_x - 1 - elapsed.chars().count() as i64,
1123 y + 4,
1124 &elapsed,
1125 content_style,
1126 );
1127
1128 for (index, branch) in rendered.branch_lines.iter().enumerate() {
1129 let row = y + 5 + index as i64;
1130 canvas.text(start_x, row, &chars.v.to_string(), border_style);
1131 canvas.text(right_x, row, &chars.v.to_string(), border_style);
1132 canvas.text(
1133 start_x + 2,
1134 row,
1135 &fit_text(branch, inner_width - 2),
1136 branch_style,
1137 );
1138 }
1139 let detail_row = y + 5 + rendered.branch_lines.len() as i64;
1140 canvas.text(start_x, detail_row, &chars.v.to_string(), border_style);
1141 canvas.text(right_x, detail_row, &chars.v.to_string(), border_style);
1142 if !rendered.detail.is_empty() {
1143 canvas.text(
1144 start_x + 2,
1145 detail_row,
1146 &fit_text(&format!("… {}", rendered.detail), inner_width - 2),
1147 content_style,
1148 );
1149 }
1150 canvas.text(
1151 start_x,
1152 y + height - 1,
1153 &format!("{}{horizontal}{}", chars.bl, chars.br),
1154 border_style,
1155 );
1156 if rendered.is_start {
1157 canvas.put(start_x - 2, y + 1, '▶', border_style);
1158 }
1159 if rendered.is_end {
1160 canvas.put(start_x + rendered.width + 1, y + 1, '■', border_style);
1161 }
1162}
1163
1164fn edge_style(
1165 pair_key: &str,
1166 transitions: &HashSet<String>,
1167 active_pair: Option<&str>,
1168) -> CanvasStyle {
1169 if active_pair == Some(pair_key) {
1170 return CanvasStyle::ActiveEdge;
1171 }
1172 if transitions.contains(pair_key) {
1173 return CanvasStyle::Taken;
1174 }
1175 CanvasStyle::Dim
1176}
1177
1178struct PendingLabel {
1179 text: String,
1180 style: CanvasStyle,
1181 from_x: i64,
1182 to_x: i64,
1183 track_y: i64,
1184 label_row: i64,
1185 graph_width: i64,
1186}
1187
1188#[allow(clippy::too_many_arguments)]
1189fn draw_segments(
1190 canvas: &mut CharCanvas,
1191 placed: &[PlacedRank],
1192 strips: &[StripGeometry],
1193 layout: &GraphLayout,
1194 transitions: &HashSet<String>,
1195 active_pair: Option<&str>,
1196 graph_width: i64,
1197 node_style: GraphNodeStyle,
1198 box_height: i64,
1199 lanes: &BackEdgeLanes,
1200) -> Vec<PendingLabel> {
1201 let mut labels = Vec::new();
1202 for rank in 0..placed.len().saturating_sub(1) {
1203 let strip = &strips[rank];
1204 if strip.segments.is_empty() {
1205 continue;
1206 }
1207 let top = &placed[rank];
1208 let bottom = &placed[rank + 1];
1209 let stub_top = top.y + cell_height(node_style, box_height);
1213 let strip_top = stub_top + lanes.below(rank).len() as i64;
1214 let arrow_y = bottom.y - 1;
1215 let strip_bottom = arrow_y - 1 - lanes.above(rank + 1).len() as i64;
1216 for segment in &strip.segments {
1217 let Some(edge) = layout
1218 .edges
1219 .iter()
1220 .find(|candidate| candidate.edge_id == segment.edge_id)
1221 else {
1222 continue;
1223 };
1224 let style = edge_style(
1225 &format!("{}->{}", edge.from, edge.to),
1226 transitions,
1227 active_pair,
1228 );
1229 let (from_x, to_x) = (segment.from_x, segment.to_x);
1230 let track_y = strip_top + segment.track;
1231 if from_x == to_x {
1232 canvas.vline(from_x, stub_top, arrow_y, style);
1233 } else {
1234 if track_y > stub_top {
1235 canvas.vline(from_x, stub_top, track_y - 1, style);
1236 }
1237 canvas.put(
1238 from_x,
1239 track_y,
1240 if to_x > from_x { '└' } else { '┘' },
1241 style,
1242 );
1243 canvas.hline(track_y, from_x.min(to_x) + 1, from_x.max(to_x) - 1, style);
1244 canvas.put(to_x, track_y, if to_x > from_x { '┐' } else { '┌' }, style);
1245 if arrow_y > track_y {
1246 canvas.vline(to_x, track_y + 1, arrow_y, style);
1247 }
1248 }
1249 if segment.target_is_node {
1250 canvas.put(to_x, arrow_y, '▼', style);
1251 }
1252 if let Some(label) = &segment.label {
1253 labels.push(PendingLabel {
1254 text: label.clone(),
1255 style,
1256 from_x,
1257 to_x,
1258 track_y,
1259 label_row: (strip_top + strip.track_count).min(strip_bottom),
1260 graph_width,
1261 });
1262 }
1263 }
1264 }
1265 labels
1266}
1267
1268fn draw_segment_label(canvas: &mut CharCanvas, label: &PendingLabel) {
1272 let padded = format!(" {} ", label.text);
1273 let padded_len = js_len(&padded);
1274 let text_len = js_len(&label.text);
1275 if label.from_x != label.to_x {
1276 let run_start = label.from_x.min(label.to_x) + 1;
1277 let run_end = label.from_x.max(label.to_x) - 1;
1278 let center = (run_start + run_end) / 2 - padded_len / 2;
1279 if run_end - run_start + 1 >= padded_len + 2
1280 && canvas.text_over_run(center, label.track_y, &padded, label.style)
1281 {
1282 return;
1283 }
1284 }
1285 let left = (label.to_x - text_len - 1, label.label_row);
1286 let right = (label.to_x + 2, label.label_row);
1287 let candidates = if label.to_x >= label.graph_width / 2 {
1288 [left, right]
1289 } else {
1290 [right, left]
1291 };
1292 for (x, y) in candidates {
1293 if canvas.text_if_empty(x, y, &label.text, label.style) {
1294 return;
1295 }
1296 }
1297 canvas.text_if_empty(label.from_x + 2, label.track_y, &label.text, label.style);
1299}
1300
1301#[allow(clippy::too_many_arguments)]
1305fn draw_back_edges(
1306 canvas: &mut CharCanvas,
1307 placed: &[PlacedRank],
1308 layout: &GraphLayout,
1309 transitions: &HashSet<String>,
1310 graph_width: i64,
1311 node_style: GraphNodeStyle,
1312 box_height: i64,
1313 lanes: &BackEdgeLanes,
1314) {
1315 let mut gutter_x = graph_width + GUTTER_GAP;
1316 for edge in &lanes.edges {
1317 let (Some(&from_rank), Some(&to_rank)) = (
1318 layout.rank_of_node.get(&edge.from),
1319 layout.rank_of_node.get(&edge.to),
1320 ) else {
1321 continue;
1322 };
1323 let from = &placed[from_rank];
1324 let to = &placed[to_rank];
1325 let below = lanes.below(from_rank);
1326 let above = lanes.above(to_rank);
1327 let (Some(exit), Some(entry)) = (
1328 cell_anchor(from, &edge.from, &below, edge),
1329 cell_anchor(to, &edge.to, &above, edge),
1330 ) else {
1331 continue;
1332 };
1333 let style = if transitions.contains(&format!("{}->{}", edge.from, edge.to)) {
1334 CanvasStyle::Taken
1335 } else {
1336 CanvasStyle::Back
1337 };
1338 let exit_lane_y = from.y + cell_height(node_style, box_height) + exit.lane;
1339 let above_count = above.len() as i64;
1340 let arrow_y = to.y - 1;
1341 let entry_lane_y = arrow_y - above_count + entry.lane;
1342
1343 if exit_lane_y > from.y + cell_height(node_style, box_height) {
1345 canvas.vline(
1346 exit.x,
1347 from.y + cell_height(node_style, box_height),
1348 exit_lane_y - 1,
1349 style,
1350 );
1351 }
1352 canvas.put(exit.x, exit_lane_y, '└', style);
1353 canvas.hline(exit_lane_y, exit.x + 1, gutter_x - 1, style);
1354 canvas.put(gutter_x, exit_lane_y, '┘', style);
1355 canvas.put(gutter_x, entry_lane_y, '┐', style);
1357 if exit_lane_y - entry_lane_y > 1 {
1358 canvas.vline(gutter_x, entry_lane_y + 1, exit_lane_y - 1, style);
1359 }
1360 canvas.hline(entry_lane_y, entry.x + 1, gutter_x - 1, style);
1361 canvas.put(entry.x, entry_lane_y, '┌', style);
1362 if arrow_y - entry_lane_y > 1 {
1363 canvas.vline(entry.x, entry_lane_y + 1, arrow_y - 1, style);
1364 }
1365 canvas.put(entry.x, arrow_y, '▼', style);
1366 if let Some(label) = &edge.label {
1367 canvas.text(gutter_x + 2, entry_lane_y, label, style);
1368 }
1369 gutter_x += 2 + edge.label.as_deref().map_or(0, |label| js_len(label) + 1);
1371 }
1372}
1373
1374struct Anchor {
1375 x: i64,
1376 lane: i64,
1377}
1378
1379fn cell_anchor(
1383 rank: &PlacedRank,
1384 node_id: &str,
1385 lane_edges: &[&GraphEdge],
1386 edge: &GraphEdge,
1387) -> Option<Anchor> {
1388 let index = rank
1389 .cells
1390 .iter()
1391 .position(|cell| matches!(&cell.cell, GraphCell::Node { node_id: id } if id == node_id))?;
1392 let lane = lane_edges
1393 .iter()
1394 .position(|candidate| candidate.edge_id == edge.edge_id)? as i64;
1395 let cell = &rank.cells[index];
1396 let center = rank.centers[index];
1397 let rightmost = center + cell.width / 2 - 1;
1398 Some(Anchor {
1399 x: (center + 2 + lane * 2).min(rightmost),
1400 lane,
1401 })
1402}