1use std::cmp::Ordering;
10use std::collections::{HashMap, HashSet};
11
12use crate::model::{Model, NodeId, NodeStatus};
13
14#[derive(Debug, Clone)]
17pub struct Placement {
18 pub id: NodeId,
19 pub status: NodeStatus,
20 pub x: u16,
21 pub y: u16,
22 pub w: u16,
23 pub h: u16,
24 pub container: bool,
27 pub selectable: bool,
31 pub member: bool,
34}
35
36#[derive(Debug, Clone, Default)]
39pub struct Layout {
40 pub lines: Vec<String>,
41 pub width: u16,
42 pub height: u16,
43 pub placements: Vec<Placement>,
44}
45
46pub trait GraphEngine {
47 fn name(&self) -> &'static str;
48 fn lay_out(&self, model: &Model, zoom: Zoom) -> Layout;
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Zoom {
56 Compact,
57 Normal,
58 Detail,
59}
60
61impl Zoom {
62 pub fn label(self) -> &'static str {
63 match self {
64 Self::Compact => "compact",
65 Self::Normal => "normal",
66 Self::Detail => "detail",
67 }
68 }
69
70 pub fn out(self) -> Self {
71 match self {
72 Self::Detail => Self::Normal,
73 _ => Self::Compact,
74 }
75 }
76
77 pub fn in_(self) -> Self {
78 match self {
79 Self::Compact => Self::Normal,
80 _ => Self::Detail,
81 }
82 }
83
84 fn metrics(self) -> (usize, usize, usize) {
86 match self {
87 Self::Compact => (1, 0, 3),
88 Self::Normal => (3, 1, 6),
89 Self::Detail => (4, 1, 8),
90 }
91 }
92
93 fn label_max(self) -> Option<usize> {
95 match self {
96 Self::Compact => Some(12),
97 _ => None,
98 }
99 }
100
101 fn show_meta(self) -> bool {
102 self == Self::Detail
103 }
104}
105
106pub struct Layered;
109
110impl GraphEngine for Layered {
111 fn name(&self) -> &'static str {
112 "layered"
113 }
114
115 fn lay_out(&self, model: &Model, zoom: Zoom) -> Layout {
116 let ranks = rank_nodes(model);
117 place_and_draw(model, &ranks, zoom)
118 }
119}
120
121fn rank_nodes(model: &Model) -> Vec<(usize, usize)> {
127 let is_flow = |index: usize| model.nodes.get(index).is_some_and(|n| n.group.is_none());
128
129 let mut preds: HashMap<usize, Vec<usize>> = HashMap::new();
130 for edge in &model.edges {
131 if let (Some(from), Some(to)) = (model.index_of(&edge.from), model.index_of(&edge.to))
132 && from != to
133 && is_flow(from)
134 && is_flow(to)
135 {
136 preds.entry(to).or_default().push(from);
137 }
138 }
139
140 let mut level: HashMap<usize, usize> = HashMap::new();
141 let mut path: HashSet<usize> = HashSet::new();
142 for index in 0..model.nodes.len() {
143 if is_flow(index) {
144 longest_path(index, &preds, &mut level, &mut path);
145 }
146 }
147
148 let mut next_row: HashMap<usize, usize> = HashMap::new();
149 let mut ranks = vec![(0usize, 0usize); model.nodes.len()];
150 for index in 0..model.nodes.len() {
151 if !is_flow(index) {
152 continue;
153 }
154 let column = level.get(&index).copied().unwrap_or(0);
155 let row = next_row.entry(column).or_default();
156 if let Some(slot) = ranks.get_mut(index) {
157 *slot = (column, *row);
158 }
159 *row += 1;
160 }
161 ranks
162}
163
164fn longest_path(
167 node: usize,
168 preds: &HashMap<usize, Vec<usize>>,
169 level: &mut HashMap<usize, usize>,
170 path: &mut HashSet<usize>,
171) -> usize {
172 if let Some(&cached) = level.get(&node) {
173 return cached;
174 }
175 path.insert(node);
176 let parents = preds.get(&node).cloned().unwrap_or_default();
177 let mut depth = 0;
178 for parent in parents {
179 if path.contains(&parent) {
180 continue;
181 }
182 depth = depth.max(longest_path(parent, preds, level, path) + 1);
183 }
184 path.remove(&node);
185 level.insert(node, depth);
186 depth
187}
188
189#[allow(clippy::too_many_lines)]
192fn place_and_draw(model: &Model, ranks: &[(usize, usize)], zoom: Zoom) -> Layout {
193 let (box_h, row_gap, col_gap) = zoom.metrics();
194 let col_of = |index: usize| ranks.get(index).map_or(0, |(c, _)| *c);
195
196 let text: Vec<String> = model
197 .nodes
198 .iter()
199 .map(|n| label_text(&n.label, n.status, zoom))
200 .collect();
201 let mut members: std::collections::HashMap<String, Vec<usize>> =
203 std::collections::HashMap::new();
204 for (index, node) in model.nodes.iter().enumerate() {
205 if let Some(group) = &node.group {
206 members.entry(group.clone()).or_default().push(index);
207 }
208 }
209 let members_of = |index: usize| -> &[usize] {
210 model
211 .nodes
212 .get(index)
213 .and_then(|n| n.foldable.as_ref())
214 .and_then(|g| members.get(g))
215 .map_or(&[][..], Vec::as_slice)
216 };
217
218 let mut size = vec![(0usize, 0usize); model.nodes.len()];
221 for (index, label) in text.iter().enumerate() {
222 if let Some(slot) = size.get_mut(index) {
223 *slot = (label.chars().count() + 4, box_h);
224 }
225 }
226 let flow: Vec<usize> = (0..model.nodes.len())
227 .filter(|&i| model.nodes.get(i).is_some_and(|n| n.group.is_none()))
228 .collect();
229 for &index in &flow {
230 let legs = members_of(index);
231 if !legs.is_empty() {
232 let inner = legs
233 .iter()
234 .map(|&m| size.get(m).map_or(0, |s| s.0))
235 .max()
236 .unwrap_or(0)
237 .max(text.get(index).map_or(0, |t| t.chars().count()));
238 if let Some(slot) = size.get_mut(index) {
239 *slot = (inner + 4, 2 + legs.len() * (box_h + 1));
240 }
241 }
242 }
243
244 let col_count = flow.iter().map(|&i| col_of(i) + 1).max().unwrap_or(1);
246 let mut col_width = vec![0usize; col_count];
247 for &index in &flow {
248 if let Some(slot) = col_width.get_mut(col_of(index)) {
249 *slot = (*slot).max(size.get(index).map_or(0, |s| s.0));
250 }
251 }
252 let width_of = |col: usize| col_width.get(col).copied().unwrap_or(0);
253 let col_x: Vec<usize> = (0..col_count)
254 .scan(0usize, |x, col| {
255 let here = *x;
256 *x += width_of(col) + col_gap;
257 Some(here)
258 })
259 .collect();
260 let x_of = |col: usize| col_x.get(col).copied().unwrap_or(0);
261
262 let mut order = flow.clone();
264 order.sort_by_key(|&i| ranks.get(i).copied().unwrap_or_default());
265 let mut node_box = vec![(0usize, 0usize); model.nodes.len()];
266 let mut col_y = vec![0usize; col_count];
267 let mut total_h = 0usize;
268 for &index in &order {
269 let col = col_of(index);
270 let y = col_y.get(col).copied().unwrap_or(0);
271 if let Some(slot) = node_box.get_mut(index) {
272 *slot = (x_of(col), y);
273 }
274 let h = size.get(index).map_or(box_h, |s| s.1);
275 if let Some(slot) = col_y.get_mut(col) {
276 *slot = y + h + row_gap.max(1);
277 }
278 total_h = total_h.max(y + h);
279 }
280 for &index in &flow {
282 let legs = members_of(index);
283 let (cx, cy) = node_box.get(index).copied().unwrap_or_default();
284 for (k, &m) in legs.iter().enumerate() {
285 if let Some(slot) = node_box.get_mut(m) {
286 *slot = (cx + 2, cy + 1 + k * (box_h + 1));
287 }
288 }
289 }
290 let node_h: Vec<usize> = size.iter().map(|s| s.1).collect();
291 let total_w = col_x
292 .last()
293 .copied()
294 .map_or(0, |x| x + col_width.last().copied().unwrap_or(0));
295
296 let mut grid = Grid::new(total_w, total_h + 2);
298 route_edges(
299 &mut grid, model, ranks, &col_width, &node_box, &node_h, total_h,
300 );
301
302 let mut placements = Vec::with_capacity(model.nodes.len());
303 for (index, node) in model.nodes.iter().enumerate() {
304 let (x, y) = node_box.get(index).copied().unwrap_or_default();
305 let (w, h) = size.get(index).copied().unwrap_or((0, box_h));
306 let label = text.get(index).map_or("", String::as_str);
307 let is_container = !members_of(index).is_empty();
308 if is_container {
309 grid.draw_cluster(x, y, w, h, label);
310 } else {
311 let meta = zoom.show_meta().then(|| status_word(node.status));
312 grid.draw_box(x, y, w, h, label, meta);
313 }
314 placements.push(Placement {
315 id: node.id.clone(),
316 status: node.status,
317 x: u16::try_from(x).unwrap_or(u16::MAX),
318 y: u16::try_from(y).unwrap_or(u16::MAX),
319 w: u16::try_from(w).unwrap_or(0),
320 h: u16::try_from(h).unwrap_or(3),
321 container: is_container,
322 selectable: node.group.is_none(),
323 member: node.group.is_some(),
324 });
325 }
326
327 Layout {
328 lines: grid.into_lines(),
329 width: u16::try_from(total_w).unwrap_or(u16::MAX),
330 height: u16::try_from(total_h + 2).unwrap_or(u16::MAX),
331 placements,
332 }
333}
334
335fn label_text(label: &str, status: NodeStatus, zoom: Zoom) -> String {
338 let mut text = label.to_owned();
339 if let Some(max) = zoom.label_max()
340 && text.chars().count() > max
341 {
342 text = text.chars().take(max.saturating_sub(1)).collect::<String>() + "…";
343 }
344 let glyph = status.glyph();
345 if glyph.is_empty() {
346 text
347 } else {
348 format!("{text} {glyph}")
349 }
350}
351
352fn status_word(status: NodeStatus) -> &'static str {
353 match status {
354 NodeStatus::Ok => "success",
355 NodeStatus::Failed => "failed",
356 NodeStatus::Running => "running",
357 NodeStatus::Queued => "queued",
358 NodeStatus::Skipped => "skipped",
359 NodeStatus::Neutral => "",
360 }
361}
362
363fn route_edges(
366 grid: &mut Grid,
367 model: &Model,
368 ranks: &[(usize, usize)],
369 col_width: &[usize],
370 node_box: &[(usize, usize)],
371 node_h: &[usize],
372 rail: usize,
373) {
374 let col_of = |index: usize| ranks.get(index).map_or(0, |(c, _)| *c);
375 let width_of = |col: usize| col_width.get(col).copied().unwrap_or(0);
376 let box_of = |index: usize| node_box.get(index).copied().unwrap_or_default();
377 let h_of = |index: usize| node_h.get(index).copied().unwrap_or(1);
378 let mid_of = |index: usize| box_of(index).1 + h_of(index) / 2;
380
381 let mut forward: Vec<Vec<usize>> = vec![Vec::new(); model.nodes.len()];
382 for edge in &model.edges {
383 let (Some(from), Some(to)) = (model.index_of(&edge.from), model.index_of(&edge.to)) else {
384 continue;
385 };
386 if col_of(to) > col_of(from) {
387 if let Some(children) = forward.get_mut(from) {
388 children.push(to);
389 }
390 } else {
391 route_back_edge(
392 grid,
393 box_of(from),
394 width_of(col_of(from)),
395 h_of(from),
396 box_of(to),
397 width_of(col_of(to)),
398 h_of(to),
399 rail,
400 );
401 }
402 }
403 for (from, children) in forward.iter().enumerate() {
404 if children.is_empty() {
405 continue;
406 }
407 let parent = (box_of(from).0, mid_of(from));
408 let targets: Vec<(usize, usize)> =
409 children.iter().map(|&c| (box_of(c).0, mid_of(c))).collect();
410 draw_fork(grid, parent, width_of(col_of(from)), &targets);
411 }
412}
413
414fn draw_fork(grid: &mut Grid, parent: (usize, usize), parent_w: usize, targets: &[(usize, usize)]) {
420 let sx = parent.0 + parent_w;
421 let sy = parent.1;
422 let nearest = targets.iter().map(|&(x, _)| x).min().unwrap_or(sx + 2);
423 if nearest <= sx + 1 {
424 return;
425 }
426 let channel = sx + (nearest - sx) / 2;
427 for x in sx..channel {
428 grid.line(x, sy, Dir::L | Dir::R);
429 }
430 let mut fork = Dir::L;
431 for &(cx, cy) in targets {
432 let ex = cx.saturating_sub(1);
433 match cy.cmp(&sy) {
434 Ordering::Equal => {
435 fork |= Dir::R;
436 for x in channel..ex {
437 grid.line(x, sy, Dir::L | Dir::R);
438 }
439 }
440 Ordering::Greater => {
441 fork |= Dir::D;
442 for y in sy + 1..cy {
443 grid.line(channel, y, Dir::U | Dir::D);
444 }
445 grid.line(channel, cy, Dir::U | Dir::R); for x in channel + 1..ex {
447 grid.line(x, cy, Dir::L | Dir::R);
448 }
449 }
450 Ordering::Less => {
451 fork |= Dir::U;
452 for y in cy + 1..sy {
453 grid.line(channel, y, Dir::U | Dir::D);
454 }
455 grid.line(channel, cy, Dir::D | Dir::R); for x in channel + 1..ex {
457 grid.line(x, cy, Dir::L | Dir::R);
458 }
459 }
460 }
461 grid.put(ex, cy, '▸');
462 }
463 grid.line(channel, sy, fork);
464}
465
466#[allow(clippy::too_many_arguments)]
470fn route_back_edge(
471 grid: &mut Grid,
472 from: (usize, usize),
473 from_w: usize,
474 from_h: usize,
475 to: (usize, usize),
476 to_w: usize,
477 to_h: usize,
478 rail: usize,
479) {
480 let fx = from.0 + from_w / 2;
481 let tx = to.0 + to_w / 2;
482 let fy = from.1 + from_h;
483 let ty = to.1 + to_h;
484 for y in fy..rail {
485 grid.line(fx, y, Dir::U | Dir::D);
486 }
487 grid.line(fx, rail, Dir::U | Dir::L);
488 let (lo, hi) = (tx.min(fx), tx.max(fx));
489 for x in lo + 1..hi {
490 grid.line(x, rail, Dir::L | Dir::R);
491 }
492 grid.line(tx, rail, Dir::U | Dir::R);
493 for y in ty + 1..rail {
494 grid.line(tx, y, Dir::U | Dir::D);
495 }
496 grid.put(tx, ty, '▴');
497}
498
499struct Grid {
500 cells: Vec<Vec<char>>,
501}
502
503struct Dir;
505impl Dir {
506 const U: u8 = 1;
507 const D: u8 = 2;
508 const L: u8 = 4;
509 const R: u8 = 8;
510}
511
512impl Grid {
513 fn new(width: usize, height: usize) -> Self {
514 Self {
515 cells: vec![vec![' '; width]; height],
516 }
517 }
518
519 fn put(&mut self, x: usize, y: usize, ch: char) {
520 if let Some(cell) = self.cells.get_mut(y).and_then(|row| row.get_mut(x)) {
521 *cell = ch;
522 }
523 }
524
525 fn line(&mut self, x: usize, y: usize, mask: u8) {
528 let Some(cell) = self.cells.get_mut(y).and_then(|row| row.get_mut(x)) else {
529 return;
530 };
531 let merged = char_to_mask(*cell) | mask;
532 *cell = mask_to_char(merged);
533 }
534
535 fn draw_box(
539 &mut self,
540 x: usize,
541 y: usize,
542 w: usize,
543 box_h: usize,
544 text: &str,
545 meta: Option<&str>,
546 ) {
547 if w < 2 || box_h == 0 {
548 return;
549 }
550 if box_h == 1 {
551 self.put(x, y, '[');
552 self.put(x + w - 1, y, ']');
553 self.write_centered(x, y, w, text);
554 return;
555 }
556 let bottom = y + box_h - 1;
557 self.put(x, y, '╭');
558 self.put(x + w - 1, y, '╮');
559 self.put(x, bottom, '╰');
560 self.put(x + w - 1, bottom, '╯');
561 for col in 1..w - 1 {
562 self.put(x + col, y, '─');
563 self.put(x + col, bottom, '─');
564 }
565 for row in y + 1..bottom {
566 self.put(x, row, '│');
567 self.put(x + w - 1, row, '│');
568 }
569 self.write_centered(x, y + 1, w, text);
570 if let Some(meta) = meta
571 && box_h >= 4
572 {
573 self.write_centered(x, y + 2, w, meta);
574 }
575 }
576
577 fn draw_cluster(&mut self, x: usize, y: usize, w: usize, h: usize, title: &str) {
580 if w < 2 || h < 2 {
581 return;
582 }
583 let bottom = y + h - 1;
584 self.put(x, y, '╭');
585 self.put(x + w - 1, y, '╮');
586 self.put(x, bottom, '╰');
587 self.put(x + w - 1, bottom, '╯');
588 for col in 1..w - 1 {
589 self.put(x + col, y, '─');
590 self.put(x + col, bottom, '─');
591 }
592 for row in y + 1..bottom {
593 self.put(x, row, '│');
594 self.put(x + w - 1, row, '│');
595 }
596 for (i, ch) in format!(" {title} ").chars().enumerate() {
597 if x + 2 + i < x + w - 1 {
598 self.put(x + 2 + i, y, ch);
599 }
600 }
601 }
602
603 fn write_centered(&mut self, x: usize, y: usize, w: usize, text: &str) {
605 let chars: Vec<char> = text.chars().collect();
606 let pad = (w.saturating_sub(2)).saturating_sub(chars.len()) / 2;
607 for (i, ch) in chars.iter().enumerate() {
608 if 1 + pad + i < w - 1 {
609 self.put(x + 1 + pad + i, y, *ch);
610 }
611 }
612 }
613
614 fn into_lines(self) -> Vec<String> {
615 self.cells
616 .into_iter()
617 .map(|row| row.into_iter().collect::<String>().trim_end().to_owned())
618 .collect()
619 }
620}
621
622fn char_to_mask(ch: char) -> u8 {
623 match ch {
624 '─' => Dir::L | Dir::R,
625 '│' => Dir::U | Dir::D,
626 '╭' => Dir::D | Dir::R,
627 '╮' => Dir::D | Dir::L,
628 '╰' => Dir::U | Dir::R,
629 '╯' => Dir::U | Dir::L,
630 '├' => Dir::U | Dir::D | Dir::R,
631 '┤' => Dir::U | Dir::D | Dir::L,
632 '┬' => Dir::D | Dir::L | Dir::R,
633 '┴' => Dir::U | Dir::L | Dir::R,
634 '┼' => Dir::U | Dir::D | Dir::L | Dir::R,
635 _ => 0,
636 }
637}
638
639fn mask_to_char(mask: u8) -> char {
640 match mask {
641 m if m == Dir::L | Dir::R => '─',
642 m if m == Dir::U | Dir::D => '│',
643 m if m == Dir::D | Dir::R => '╭',
644 m if m == Dir::D | Dir::L => '╮',
645 m if m == Dir::U | Dir::R => '╰',
646 m if m == Dir::U | Dir::L => '╯',
647 m if m == Dir::U | Dir::D | Dir::R => '├',
648 m if m == Dir::U | Dir::D | Dir::L => '┤',
649 m if m == Dir::D | Dir::L | Dir::R => '┬',
650 m if m == Dir::U | Dir::L | Dir::R => '┴',
651 0 => ' ',
652 _ => '┼',
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn layered_draws_boxes_and_places_every_node() {
662 let model = Model::demo();
663 let layout = Layered.lay_out(&model, Zoom::Normal);
664 assert_eq!(layout.placements.len(), model.nodes.len());
665 let art = layout.lines.join("\n");
666 assert!(
667 art.contains('╭') && art.contains('╯'),
668 "rounded boxes drawn"
669 );
670 assert!(art.contains('▸'), "edges have arrowheads");
671 for p in &layout.placements {
673 let row: Vec<char> = layout.lines[p.y as usize].chars().collect();
674 assert_eq!(
675 row.get(p.x as usize),
676 Some(&'╭'),
677 "{:?} top-left corner",
678 p.id
679 );
680 }
681 }
682
683 #[test]
684 fn cyclic_graph_lays_out_without_panicking() {
685 use crate::model::{Edge, Node, RankDir};
686 let mut model = Model::new(RankDir::LeftRight);
687 let n = |id: &str| Node::leaf(id, NodeStatus::Neutral);
688 model.nodes = vec![n("a"), n("b")];
689 let e = |a: &str, b: &str| Edge {
690 from: NodeId::new(a),
691 to: NodeId::new(b),
692 label: None,
693 };
694 model.edges = vec![e("a", "b"), e("b", "a")];
695 assert_eq!(Layered.lay_out(&model, Zoom::Normal).placements.len(), 2);
696 }
697}