1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum GraphAction {
22 Activated(NodeId),
24 Folded { group: String, collapsed: bool },
26}
27
28const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
30
31pub struct GraphView {
32 model: Model,
33 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 pub fn set_model(&mut self, model: Model) {
82 self.model = model;
83 self.relayout();
84 }
85
86 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 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 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 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 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 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 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
429fn 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}