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