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