Skip to main content

diffler_graph/
view.rs

1//! `GraphView`: the reusable, IO-free graph component. The host pushes a
2//! [`Model`] in, renders it into any area, and reacts to the [`GraphAction`]s
3//! it emits. It owns only view state (selection, scroll, zoom, collapsed
4//! groups) — no terminal, no event loop, no sources.
5
6use std::collections::HashSet;
7
8use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
9use ratatui::buffer::Buffer;
10use ratatui::layout::Rect;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::Line;
13use ratatui::widgets::{Paragraph, Widget};
14
15use crate::engine::{GraphEngine, Layered, Layout, Placement, Zoom};
16use crate::model::{Model, NodeId, NodeStatus};
17use crate::theme::GraphTheme;
18
19/// What the component asks the host to do. The host owns the side effects.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum GraphAction {
22    /// Enter / double-click on a non-foldable node — open it (code, log, …).
23    Activated(NodeId),
24    /// A group was folded or unfolded.
25    Folded { group: String, collapsed: bool },
26}
27
28/// Two left-presses within this window (at ~the same cell) are a double-click.
29const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
30
31pub struct GraphView {
32    model: Model,
33    // `+ Send` so an embedding host that crosses threads/await points (the
34    // review app is spawned in tests) keeps `App: Send`
35    engine: Box<dyn GraphEngine + Send>,
36    layout: Layout,
37    selected: Option<NodeId>,
38    scroll_x: u16,
39    scroll_y: u16,
40    viewport: Rect,
41    zoom: Zoom,
42    collapsed: HashSet<String>,
43    last_click: Option<(std::time::Instant, u16, u16)>,
44}
45
46impl Default for GraphView {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl GraphView {
53    #[must_use]
54    pub fn new() -> Self {
55        Self {
56            model: Model::new(crate::model::RankDir::LeftRight),
57            engine: Box::new(Layered),
58            layout: Layout::default(),
59            selected: None,
60            scroll_x: 0,
61            scroll_y: 0,
62            viewport: Rect::default(),
63            zoom: Zoom::Normal,
64            collapsed: HashSet::new(),
65            last_click: None,
66        }
67    }
68
69    pub fn zoom(&self) -> Zoom {
70        self.zoom
71    }
72
73    pub fn selected(&self) -> Option<&NodeId> {
74        self.selected.as_ref()
75    }
76
77    // --- state in: the host signals state dynamically ---
78
79    /// Replace the whole graph (topology changed). Keeps the selection if it
80    /// survives.
81    pub fn set_model(&mut self, model: Model) {
82        self.model = model;
83        self.relayout();
84    }
85
86    /// Update node statuses in place (e.g. a live CI poll) without changing
87    /// topology, then re-lay out — positions stay put, colors/glyphs move.
88    pub fn patch_status(&mut self, updates: impl IntoIterator<Item = (NodeId, NodeStatus)>) {
89        for (id, status) in updates {
90            if let Some(node) = self.model.nodes.iter_mut().find(|n| n.id == id) {
91                node.status = status;
92            }
93        }
94        self.relayout();
95    }
96
97    pub fn set_zoom(&mut self, zoom: Zoom) {
98        self.zoom = zoom;
99        self.relayout();
100    }
101
102    pub fn set_collapsed(&mut self, group: &str, collapsed: bool) {
103        let changed = if collapsed {
104            self.collapsed.insert(group.to_owned())
105        } else {
106            self.collapsed.remove(group)
107        };
108        if changed {
109            self.relayout();
110        }
111    }
112
113    pub fn select(&mut self, id: &NodeId) {
114        if self.layout.placements.iter().any(|p| &p.id == id) {
115            self.selected = Some(id.clone());
116            self.ensure_visible();
117        }
118    }
119
120    // --- input: pure; returns an action for the host ---
121
122    /// Handle a key. Returns an action the host should act on, or `None`.
123    /// Navigation/zoom/collapse are handled internally; Enter on a non-foldable
124    /// node yields `Activated`. Quit/back keys are the host's concern.
125    pub fn on_key(&mut self, key: KeyEvent) -> Option<GraphAction> {
126        if key.kind == KeyEventKind::Release {
127            return None;
128        }
129        let action = match key.code {
130            KeyCode::Char('h') | KeyCode::Left => self.step_none(Dir::Left),
131            KeyCode::Char('l') | KeyCode::Right => self.step_none(Dir::Right),
132            KeyCode::Char('k') | KeyCode::Up => self.step_none(Dir::Up),
133            KeyCode::Char('j') | KeyCode::Down => self.step_none(Dir::Down),
134            KeyCode::Char('n') => self.follow_none(true),
135            KeyCode::Char('N') => self.follow_none(false),
136            KeyCode::Char('g') => self.end_none(true),
137            KeyCode::Char('G') => self.end_none(false),
138            KeyCode::Char('+' | '=') => {
139                self.set_zoom(self.zoom.in_());
140                None
141            }
142            KeyCode::Char('-' | '_') => {
143                self.set_zoom(self.zoom.out());
144                None
145            }
146            KeyCode::Enter | KeyCode::Char('c') => return self.activate(),
147            _ => None,
148        };
149        self.ensure_visible();
150        action
151    }
152
153    /// Handle a mouse event: click selects (a leg wins over its container),
154    /// double-click activates (fold or open), wheel pans.
155    pub fn on_mouse(&mut self, mouse: MouseEvent) -> Option<GraphAction> {
156        match mouse.kind {
157            MouseEventKind::Down(MouseButton::Left) => {
158                let double = self.is_double_click(mouse.column, mouse.row);
159                let id = self.node_at(mouse.column, mouse.row)?;
160                self.selected = Some(id);
161                self.ensure_visible();
162                if double {
163                    return self.activate();
164                }
165                None
166            }
167            MouseEventKind::ScrollDown => {
168                self.scroll_y = self.scroll_y.saturating_add(2);
169                None
170            }
171            MouseEventKind::ScrollUp => {
172                self.scroll_y = self.scroll_y.saturating_sub(2);
173                None
174            }
175            _ => None,
176        }
177    }
178
179    // --- render ---
180
181    pub fn render(&mut self, area: Rect, buf: &mut Buffer, theme: &GraphTheme) {
182        self.viewport = area;
183        self.ensure_visible();
184        let lines: Vec<Line<'static>> = self
185            .layout
186            .lines
187            .iter()
188            .map(|l| Line::styled(l.clone(), Style::new().fg(theme.dim).bg(theme.bg)))
189            .collect();
190        Paragraph::new(lines)
191            .scroll((self.scroll_y, self.scroll_x))
192            .render(area, buf);
193        self.paint_nodes(area, buf, theme);
194    }
195
196    // --- internals ---
197
198    fn relayout(&mut self) {
199        let view = self.model.collapse(&self.collapsed);
200        self.layout = self.engine.lay_out(&view, self.zoom);
201        let gone = self.selected.as_ref().is_none_or(|id| {
202            !self
203                .layout
204                .placements
205                .iter()
206                .any(|p| &p.id == id && p.selectable)
207        });
208        if gone {
209            self.selected = self.first_selectable();
210        }
211    }
212
213    /// Enter on the selection: fold a group root, else open a plain node.
214    fn activate(&mut self) -> Option<GraphAction> {
215        let id = self.selected.clone()?;
216        if let Some(group) = self.model.foldable_of(&id) {
217            let collapsed = !self.collapsed.remove(&group);
218            if collapsed {
219                self.collapsed.insert(group.clone());
220            }
221            self.relayout();
222            self.ensure_visible();
223            Some(GraphAction::Folded { group, collapsed })
224        } else {
225            Some(GraphAction::Activated(id))
226        }
227    }
228
229    fn step_none(&mut self, dir: Dir) -> Option<GraphAction> {
230        self.step(dir);
231        None
232    }
233
234    fn follow_none(&mut self, forward: bool) -> Option<GraphAction> {
235        self.follow(forward);
236        None
237    }
238
239    fn end_none(&mut self, top: bool) -> Option<GraphAction> {
240        self.select_end(top);
241        None
242    }
243
244    fn selected_placement(&self) -> Option<&Placement> {
245        let id = self.selected.as_ref()?;
246        self.layout.placements.iter().find(|p| &p.id == id)
247    }
248
249    fn step(&mut self, dir: Dir) {
250        let Some(from) = self.selected_placement().map(center) else {
251            return;
252        };
253        let best = self
254            .layout
255            .placements
256            .iter()
257            .filter_map(|p| {
258                let reachable = match dir {
259                    Dir::Left | Dir::Right => p.selectable,
260                    Dir::Up | Dir::Down => p.selectable || p.member,
261                };
262                if !reachable {
263                    return None;
264                }
265                let to = center(p);
266                let (dx, dy) = (
267                    i32::from(to.0) - i32::from(from.0),
268                    i32::from(to.1) - i32::from(from.1),
269                );
270                let ahead = match dir {
271                    Dir::Left => dx < 0,
272                    Dir::Right => dx > 0,
273                    Dir::Up => dy < 0,
274                    Dir::Down => dy > 0,
275                };
276                if !ahead {
277                    return None;
278                }
279                let cost = match dir {
280                    Dir::Left | Dir::Right => dx.abs() * 3 + dy.abs(),
281                    Dir::Up | Dir::Down => dy.abs() * 3 + dx.abs(),
282                };
283                Some((cost, p.id.clone()))
284            })
285            .min_by_key(|(cost, _)| *cost);
286        if let Some((_, id)) = best {
287            self.selected = Some(id);
288        }
289    }
290
291    fn follow(&mut self, forward: bool) {
292        let Some(id) = self.selected.clone() else {
293            return;
294        };
295        let next = self.model.edges.iter().find_map(|e| {
296            if forward && e.from == id {
297                Some(e.to.clone())
298            } else if !forward && e.to == id {
299                Some(e.from.clone())
300            } else {
301                None
302            }
303        });
304        if let Some(next) = next {
305            self.selected = Some(next);
306        }
307    }
308
309    fn select_end(&mut self, top: bool) {
310        let mut navigable = self.layout.placements.iter().filter(|p| p.selectable);
311        let pick = if top {
312            navigable.next()
313        } else {
314            navigable.next_back()
315        };
316        if let Some(p) = pick {
317            self.selected = Some(p.id.clone());
318        }
319    }
320
321    fn first_selectable(&self) -> Option<NodeId> {
322        self.layout
323            .placements
324            .iter()
325            .find(|p| p.selectable)
326            .map(|p| p.id.clone())
327    }
328
329    fn ensure_visible(&mut self) {
330        let (vw, vh) = (self.viewport.width, self.viewport.height);
331        if vw == 0 || vh == 0 {
332            return;
333        }
334        let Some((x, y, w, h)) = self.selected_placement().map(|p| (p.x, p.y, p.w, p.h)) else {
335            return;
336        };
337        if x < self.scroll_x {
338            self.scroll_x = x;
339        } else if x + w >= self.scroll_x + vw {
340            self.scroll_x = (x + w).saturating_sub(vw).saturating_add(1);
341        }
342        if y < self.scroll_y {
343            self.scroll_y = y;
344        } else if y + h >= self.scroll_y + vh {
345            self.scroll_y = (y + h).saturating_sub(vh).saturating_add(1);
346        }
347        self.scroll_x = self.scroll_x.min(self.layout.width.saturating_sub(vw));
348        self.scroll_y = self.scroll_y.min(self.layout.height.saturating_sub(vh));
349    }
350
351    fn node_at(&self, col: u16, row: u16) -> Option<NodeId> {
352        let v = self.viewport;
353        if col < v.x || col >= v.x + v.width || row < v.y || row >= v.y + v.height {
354            return None;
355        }
356        let gx = self.scroll_x + (col - v.x);
357        let gy = self.scroll_y + (row - v.y);
358        let covers = |p: &&Placement| gx >= p.x && gx < p.x + p.w && gy >= p.y && gy < p.y + p.h;
359        self.layout
360            .placements
361            .iter()
362            .find(|p| p.member && covers(p))
363            .or_else(|| {
364                self.layout
365                    .placements
366                    .iter()
367                    .find(|p| p.selectable && covers(p))
368            })
369            .map(|p| p.id.clone())
370    }
371
372    fn is_double_click(&mut self, col: u16, row: u16) -> bool {
373        let now = std::time::Instant::now();
374        let double = self.last_click.is_some_and(|(at, c, r)| {
375            now.duration_since(at) < DOUBLE_CLICK_WINDOW && c.abs_diff(col) <= 1 && r == row
376        });
377        self.last_click = if double { None } else { Some((now, col, row)) };
378        double
379    }
380
381    /// Recolor each node's cells by status (containers: border only, so member
382    /// boxes keep their own colors); the selection is bold + reversed.
383    fn paint_nodes(&self, area: Rect, buf: &mut Buffer, theme: &GraphTheme) {
384        for p in &self.layout.placements {
385            let selected = self.selected.as_ref() == Some(&p.id);
386            let color = theme.status(p.status);
387            for row in 0..p.h {
388                let gy = p.y + row;
389                if gy < self.scroll_y {
390                    continue;
391                }
392                let sy = area.y + (gy - self.scroll_y);
393                if sy >= area.y + area.height {
394                    continue;
395                }
396                let border_row = row == 0 || row + 1 == p.h;
397                for col in 0..p.w {
398                    if p.container && !border_row && col != 0 && col + 1 != p.w {
399                        continue;
400                    }
401                    let gx = p.x + col;
402                    if gx < self.scroll_x {
403                        continue;
404                    }
405                    let sx = area.x + (gx - self.scroll_x);
406                    if sx >= area.x + area.width {
407                        continue;
408                    }
409                    if let Some(cell) = buf.cell_mut((sx, sy)) {
410                        cell.set_fg(color);
411                        if selected {
412                            cell.modifier.insert(Modifier::BOLD | Modifier::REVERSED);
413                        }
414                    }
415                }
416            }
417        }
418    }
419}
420
421#[derive(Clone, Copy)]
422enum Dir {
423    Left,
424    Right,
425    Up,
426    Down,
427}
428
429/// The point navigation measures from. A container uses its top so `j` enters
430/// the first leg and `j`/`k` cycle the legs cleanly.
431fn center(p: &Placement) -> (u16, u16) {
432    let cy = if p.container { p.y } else { p.y + p.h / 2 };
433    (p.x + p.w / 2, cy)
434}
435
436#[cfg(test)]
437mod tests {
438    use ratatui::Terminal;
439    use ratatui::backend::TestBackend;
440    use ratatui::style::Color;
441
442    use super::*;
443
444    fn theme() -> GraphTheme {
445        GraphTheme {
446            bg: Color::Black,
447            fg: Color::White,
448            dim: Color::DarkGray,
449            ok: Color::Green,
450            failed: Color::Red,
451            running: Color::Yellow,
452            queued: Color::DarkGray,
453            panel: Color::Black,
454        }
455    }
456
457    fn view() -> GraphView {
458        let mut v = GraphView::new();
459        v.set_model(Model::demo());
460        v
461    }
462
463    fn key(c: char) -> KeyEvent {
464        KeyEvent::new(KeyCode::Char(c), crossterm::event::KeyModifiers::NONE)
465    }
466
467    fn render(v: &mut GraphView) -> Terminal<TestBackend> {
468        let backend = TestBackend::new(120, 40);
469        let mut terminal = Terminal::new(backend).expect("terminal");
470        terminal
471            .draw(|frame| v.render(frame.area(), frame.buffer_mut(), &theme()))
472            .expect("draw");
473        terminal
474    }
475
476    #[test]
477    fn renders_the_demo_with_a_group_container() {
478        let mut v = view();
479        let art = render(&mut v).backend().to_string();
480        assert!(art.contains("▾ test"), "group root marked foldable: {art}");
481        assert!(art.contains("test ubuntu"), "legs shown when expanded");
482        insta::assert_snapshot!(render(&mut v).backend());
483    }
484
485    #[test]
486    fn horizontal_nav_enters_the_gate_vertical_enters_legs() {
487        let mut v = view();
488        render(&mut v);
489        v.select(&NodeId::new("lint"));
490        v.on_key(key('l'));
491        assert_eq!(
492            v.selected(),
493            Some(&NodeId::new("test")),
494            "l lands on the container gate"
495        );
496        v.on_key(key('j'));
497        assert_eq!(
498            v.selected().map(|id| id.0.as_str()),
499            Some("test ubuntu"),
500            "j descends into the first leg"
501        );
502    }
503
504    #[test]
505    fn enter_folds_a_group_and_emits_an_action() {
506        let mut v = view();
507        render(&mut v);
508        v.select(&NodeId::new("test"));
509        let action = v.on_key(KeyEvent::new(
510            KeyCode::Enter,
511            crossterm::event::KeyModifiers::NONE,
512        ));
513        assert_eq!(
514            action,
515            Some(GraphAction::Folded {
516                group: "test".to_owned(),
517                collapsed: true,
518            })
519        );
520        assert!(render(&mut v).backend().to_string().contains("▸ test (3)"));
521    }
522
523    #[test]
524    fn enter_on_a_plain_node_activates_it() {
525        let mut v = view();
526        render(&mut v);
527        v.select(&NodeId::new("build"));
528        let action = v.on_key(KeyEvent::new(
529            KeyCode::Enter,
530            crossterm::event::KeyModifiers::NONE,
531        ));
532        assert_eq!(action, Some(GraphAction::Activated(NodeId::new("build"))));
533    }
534
535    #[test]
536    fn patch_status_updates_a_node_without_changing_topology() {
537        let mut v = view();
538        render(&mut v);
539        let before = v.layout.placements.len();
540        v.patch_status([(NodeId::new("build"), NodeStatus::Failed)]);
541        assert_eq!(v.layout.placements.len(), before, "topology unchanged");
542        assert!(render(&mut v).backend().to_string().contains("build ✗"));
543    }
544}