Skip to main content

diffler_graph/
engine.rs

1//! Layout + render engines behind one trait, so the renderer is swappable
2//! (ego-centric/radial for LSP maps later) without touching the view.
3//! The view consumes an owned [`Layout`] (no engine lifetimes leak out).
4//!
5//! `Layered` is the favoured engine: longest-path layering assigns each node a
6//! column, then it draws the GitHub-style look — outlined rounded boxes laid out
7//! left-to-right, wired by clean orthogonal rails.
8
9use std::cmp::Ordering;
10use std::collections::{HashMap, HashSet};
11
12use crate::model::{Model, NodeId, NodeStatus};
13
14/// An owned node rectangle in layout-grid cells, plus what the view needs to
15/// color it.
16#[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    /// A group container (its members are drawn inside). The view colors only
25    /// its border, leaving the member boxes their own colors.
26    pub container: bool,
27    /// A top-level nav gate (ordinary node or container). Horizontal moves and
28    /// `g`/`G` only land on these — crossing columns enters a group at its
29    /// container, never a leg.
30    pub selectable: bool,
31    /// A group leg drawn inside a container. Reachable by vertical moves (enter
32    /// the group with `j`/`k`) and by clicking it directly.
33    pub member: bool,
34}
35
36/// Engine output: the rendered art grid plus node placements, all owned so the
37/// view holds it across frames without borrowing the engine.
38#[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/// Level-of-detail. Terminal cells can't sub-cell scale, so "zoom" trades box
52/// size + label detail for how much graph fits: out = compact overview, in =
53/// roomy boxes with a status line.
54#[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    /// `(box height, row gap, column gap)`.
85    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    /// Max label chars before eliding (compact only).
94    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
106/// GitHub-style layered renderer: longest-path layering ranks the nodes; we draw
107/// rounded outlined boxes left-to-right and route orthogonal rails between columns.
108pub 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
121/// Per-node `(column, row-within-column)` from a layered pass: the column is the
122/// longest path from a source; within a column, nodes keep declaration order.
123/// Only flow nodes (ordinary + group roots) are ranked — group members live
124/// inside their root's container, not in the column flow. Cycles (call/reference
125/// graphs) are tolerated: a back-edge to a node already on the path adds no depth.
126fn 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
164/// Longest path from a source to `node`, memoized into `level`. A predecessor
165/// already on the current `path` is a cycle back-edge and contributes no depth.
166fn 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// cohesive layout pass: size nodes, place columns, place container members,
190// route, draw — splitting it would only scatter the shared coordinate maps
191#[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    // members per group root, in model order
202    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    // size every node: members get a plain box; a flow node is either a box or,
219    // when it has present members, a container sized to hold them stacked
220    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    // columns over flow nodes; a column shares its widest unit's width
245    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    // stack flow units top-to-bottom (cumulative — units vary in height)
263    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    // position members inside their container
281    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    // two extra rows below carry the return rail for back edges
297    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
335/// The box label: the node label plus its status glyph, elided to the zoom's
336/// max width (compact) so overview boxes stay small.
337fn 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
363/// Route every edge: group forward edges by parent into one clean fork each;
364/// back edges (cycles) loop under the boxes via the return rail at `rail`.
365fn 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    // an edge connects at a node's vertical centre (containers are tall)
379    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
414/// Draw one parent's fan-out as a single fork: a stub out of the parent to a
415/// shared channel, one junction there (├ ┬ ┤ …), then a vertical down/up to each
416/// child's row and a stub into it with an arrowhead. One junction per parent —
417/// no independent crossings, no stray stubs (corners terminate every rail).
418/// `targets` are each child's `(left_x, mid_y)`.
419fn 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); // ╰
446                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); // ╭
456                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/// A cycle's back edge: down from the parent's bottom to a rail below all boxes,
467/// left along the rail, up into the child's bottom (arrow points up).
468// endpoints + sizes + rail: a self-contained orthogonal route, not worth a struct
469#[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
503/// Direction bits for merging box-drawing line characters at junctions.
504struct 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    /// Plot a line segment, merging with any line already there so crossings and
526    /// branches render as proper junctions (├ ┬ ┼ …).
527    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    /// Draw a node box `box_h` rows tall. `box_h == 1` is the compact overview
536    /// form `[ label ]` (no top/bottom rule); taller boxes are rounded outlines
537    /// with the label on the first content row and `meta` on the next, if given.
538    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    /// Draw a group container: a rounded outline with `title` set into the top
578    /// border (`╭─ ▾ test ✗ ─╮`). Member boxes are drawn separately inside it.
579    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    /// Center `text` within the box interior (`w - 2`) on row `y`.
604    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        // every placement's top-left cell is a box corner
672        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}