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