1use std::io;
21use std::path::Path;
22use std::time::{Duration, Instant, SystemTime};
23
24use ansi_to_tui::IntoText;
25use anyhow::{Context as _, Result};
26use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
27use crossterm::execute;
28use crossterm::terminal::{
29 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
30};
31use ratatui::Frame;
32use ratatui::backend::{Backend, CrosstermBackend};
33use ratatui::layout::{Constraint, Direction, Layout, Rect};
34use ratatui::style::{Color, Modifier, Style};
35use ratatui::text::{Line, Span};
36use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
37use ratatui::{Terminal, TerminalOptions, Viewport};
38
39use crate::report;
40use crate::run::{self, RunState, RunStatus};
41
42const REFRESH: Duration = Duration::from_millis(1000);
44const TICK: Duration = Duration::from_millis(200);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Focus {
50 List,
52 Detail,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Filter {
59 All,
61 Active,
63 Done,
65 Attention,
67}
68
69impl Filter {
70 pub fn next(self) -> Self {
72 match self {
73 Self::All => Self::Active,
74 Self::Active => Self::Attention,
75 Self::Attention => Self::Done,
76 Self::Done => Self::All,
77 }
78 }
79
80 pub fn label(self) -> &'static str {
82 match self {
83 Self::All => "all",
84 Self::Active => "active",
85 Self::Done => "done",
86 Self::Attention => "attention",
87 }
88 }
89
90 pub fn accepts(self, status: RunStatus) -> bool {
92 match self {
93 Self::All => true,
94 Self::Active => !status.done(),
95 Self::Done => matches!(
99 status,
100 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded
101 ),
102 Self::Attention => {
109 matches!(
110 status,
111 RunStatus::Stalled
112 | RunStatus::Blocked
113 | RunStatus::Failed
114 | RunStatus::VerifiedNoop
115 )
116 }
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
123struct Loaded {
124 id: String,
125 mtime: Option<SystemTime>,
126 state: RunState,
127}
128
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131pub struct Counts {
132 pub total: usize,
134 pub active: usize,
136 pub done: usize,
138 pub attention: usize,
140 pub unreadable: usize,
142}
143
144pub struct App {
146 runs: Vec<Loaded>,
147 cursor: usize,
149 scroll: u16,
151 focus: Focus,
152 filter: Filter,
153 unreadable: usize,
155 help: bool,
156 status: Option<String>,
157 last_refresh: Instant,
158 quit: bool,
160}
161
162impl App {
163 pub fn new(states: Vec<RunState>) -> Self {
166 let runs = states
167 .into_iter()
168 .map(|state| Loaded {
169 id: state.id.clone(),
170 mtime: None,
171 state,
172 })
173 .collect();
174 Self {
175 runs,
176 cursor: 0,
177 scroll: 0,
178 focus: Focus::List,
179 filter: Filter::All,
180 unreadable: 0,
181 help: false,
182 status: None,
183 last_refresh: Instant::now(),
184 quit: false,
185 }
186 }
187
188 pub fn load() -> Self {
190 let mut app = Self::new(Vec::new());
191 app.refresh();
192 app
193 }
194
195 pub fn refresh(&mut self) {
208 let selected_id = self.selected().map(|s| s.id.clone());
209 let ids = run::list_ids();
210 let mut next: Vec<Loaded> = Vec::with_capacity(ids.len());
211 let mut unreadable = 0usize;
212 for id in ids {
213 let mtime = state_mtime(&id);
214 let previous = self.runs.iter().find(|l| l.id == id);
215 if let Some(l) = previous.filter(|l| l.mtime == mtime && mtime.is_some()) {
216 next.push(l.clone());
217 continue;
218 }
219 match RunState::load(&id) {
220 Ok(state) => next.push(Loaded { id, mtime, state }),
221 Err(_) => match previous {
222 Some(stale) => next.push(stale.clone()),
223 None => unreadable += 1,
224 },
225 }
226 }
227 self.runs = next;
228 self.unreadable = unreadable;
229 self.last_refresh = Instant::now();
230 if let Some(id) = selected_id
232 && let Some(pos) = self.visible().iter().position(|i| self.runs[*i].id == id)
233 {
234 self.cursor = pos;
235 }
236 self.clamp();
237 }
238
239 pub fn visible(&self) -> Vec<usize> {
241 self.runs
242 .iter()
243 .enumerate()
244 .filter(|(_, l)| self.filter.accepts(l.state.status))
245 .map(|(i, _)| i)
246 .collect()
247 }
248
249 pub fn selected(&self) -> Option<&RunState> {
251 let visible = self.visible();
252 visible.get(self.cursor).map(|i| &self.runs[*i].state)
253 }
254
255 pub fn counts(&self) -> Counts {
257 let mut c = Counts {
258 total: self.runs.len(),
259 unreadable: self.unreadable,
260 ..Counts::default()
261 };
262 for l in &self.runs {
263 match l.state.status {
264 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded => c.done += 1,
265 RunStatus::Stalled
266 | RunStatus::Blocked
267 | RunStatus::Failed
268 | RunStatus::VerifiedNoop => c.attention += 1,
269 _ => c.active += 1,
270 }
271 }
272 c
273 }
274
275 fn clamp(&mut self) {
276 let len = self.visible().len();
277 self.cursor = if len == 0 {
278 0
279 } else {
280 self.cursor.min(len - 1)
281 };
282 }
283
284 pub fn next_run(&mut self) {
286 let len = self.visible().len();
287 if len > 0 {
288 self.cursor = (self.cursor + 1) % len;
289 self.scroll = 0;
290 }
291 }
292
293 pub fn prev_run(&mut self) {
295 let len = self.visible().len();
296 if len > 0 {
297 self.cursor = (self.cursor + len - 1) % len;
298 self.scroll = 0;
299 }
300 }
301
302 pub fn first_run(&mut self) {
304 self.cursor = 0;
305 self.scroll = 0;
306 }
307
308 pub fn last_run(&mut self) {
310 self.cursor = self.visible().len().saturating_sub(1);
311 self.scroll = 0;
312 }
313
314 pub fn scroll_by(&mut self, delta: i32) {
316 let next = i32::from(self.scroll).saturating_add(delta);
317 self.scroll = next.clamp(0, i32::from(u16::MAX)) as u16;
318 }
319
320 pub fn cycle_filter(&mut self) {
322 self.filter = self.filter.next();
323 self.cursor = 0;
324 self.scroll = 0;
325 self.status = Some(format!("filter: {}", self.filter.label()));
326 }
327
328 pub fn toggle_focus(&mut self) {
330 self.focus = match self.focus {
331 Focus::List => Focus::Detail,
332 Focus::Detail => Focus::List,
333 };
334 }
335
336 pub fn filter(&self) -> Filter {
338 self.filter
339 }
340
341 pub fn focus(&self) -> Focus {
343 self.focus
344 }
345
346 pub fn quitting(&self) -> bool {
348 self.quit
349 }
350
351 fn detail(&self) -> String {
353 match self.selected() {
354 Some(state) => report::run(state),
355 None => String::from("no runs yet\n\nrun `magi run \"<task>\"` in a repository."),
356 }
357 }
358
359 pub fn on_key(&mut self, key: KeyEvent) {
361 if key.kind == KeyEventKind::Release {
362 return;
363 }
364 self.status = None;
365 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
366
367 if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
371 || (ctrl && matches!(key.code, KeyCode::Char('c')))
372 {
373 self.quit = true;
374 return;
375 }
376
377 if self.help {
380 self.help = false;
381 return;
382 }
383
384 match key.code {
385 KeyCode::Char('?') => self.help = true,
386 KeyCode::Tab | KeyCode::BackTab => self.toggle_focus(),
387 KeyCode::Char('a') => self.cycle_filter(),
388 KeyCode::Char('r') => {
389 self.refresh();
390 self.status = Some("refreshed".to_owned());
391 }
392 KeyCode::Char('o') => self.open_selected(),
393 KeyCode::Char('g') | KeyCode::Home => self.first_run(),
394 KeyCode::Char('G') | KeyCode::End => self.last_run(),
395 KeyCode::Char('J') => self.scroll_by(5),
396 KeyCode::Char('K') => self.scroll_by(-5),
397 KeyCode::PageDown => self.scroll_by(20),
398 KeyCode::PageUp => self.scroll_by(-20),
399 KeyCode::Char('j') | KeyCode::Down => match self.focus {
400 Focus::List => self.next_run(),
401 Focus::Detail => self.scroll_by(1),
402 },
403 KeyCode::Char('k') | KeyCode::Up => match self.focus {
404 Focus::List => self.prev_run(),
405 Focus::Detail => self.scroll_by(-1),
406 },
407 _ => {}
408 }
409 }
410
411 fn open_selected(&mut self) {
414 let Some(dir) = self.selected().map(|s| s.dir()) else {
415 return;
416 };
417 self.status = Some(match open_path(&dir) {
418 Ok(()) => format!("opened {}", dir.display()),
419 Err(e) => format!("could not open {}: {e}", dir.display()),
420 });
421 }
422
423 fn tick(&mut self) {
425 if self.last_refresh.elapsed() >= REFRESH {
426 self.refresh();
427 }
428 }
429}
430
431fn state_mtime(id: &str) -> Option<SystemTime> {
432 std::fs::metadata(run::run_dir(id).join("run.json"))
433 .and_then(|m| m.modified())
434 .ok()
435}
436
437#[cfg(windows)]
438fn open_path(path: &Path) -> Result<()> {
439 std::process::Command::new("explorer")
440 .arg(path)
441 .spawn()
442 .map(|_| ())
443 .context("spawn explorer")
444}
445
446#[cfg(target_os = "macos")]
447fn open_path(path: &Path) -> Result<()> {
448 std::process::Command::new("open")
449 .arg(path)
450 .spawn()
451 .map(|_| ())
452 .context("spawn open")
453}
454
455#[cfg(all(unix, not(target_os = "macos")))]
456fn open_path(path: &Path) -> Result<()> {
457 std::process::Command::new("xdg-open")
458 .arg(path)
459 .spawn()
460 .map(|_| ())
461 .context("spawn xdg-open")
462}
463
464fn status_style(status: RunStatus) -> Style {
466 match status {
467 RunStatus::Merged => Style::default()
468 .fg(Color::Green)
469 .add_modifier(Modifier::BOLD),
470 RunStatus::Ready => Style::default().fg(Color::Green),
471 RunStatus::Stalled => Style::default()
472 .fg(Color::Yellow)
473 .add_modifier(Modifier::BOLD),
474 RunStatus::Blocked => Style::default().fg(Color::Yellow),
475 RunStatus::Failed => Style::default().fg(Color::Red),
476 RunStatus::VerifiedNoop => Style::default().fg(Color::Cyan),
480 RunStatus::Superseded => Style::default().fg(Color::DarkGray),
483 _ => Style::default().fg(Color::Cyan),
484 }
485}
486
487pub fn draw(frame: &mut Frame, app: &mut App) {
489 let chunks = Layout::default()
490 .direction(Direction::Vertical)
491 .constraints([
492 Constraint::Length(1),
493 Constraint::Min(3),
494 Constraint::Length(1),
495 ])
496 .split(frame.area());
497
498 header(frame, chunks[0], app);
499 body(frame, chunks[1], app);
500 footer(frame, chunks[2], app);
501
502 if app.help {
503 help_overlay(frame, frame.area());
504 }
505}
506
507fn header(frame: &mut Frame, area: Rect, app: &App) {
508 let c = app.counts();
509 let mut line = Line::from(vec![
510 Span::styled(
511 " magi ",
512 Style::default()
513 .fg(Color::Black)
514 .bg(Color::Cyan)
515 .add_modifier(Modifier::BOLD),
516 ),
517 Span::raw(format!(" {} runs ", c.total)),
518 Span::styled(
519 format!("{} active", c.active),
520 status_style(RunStatus::Prep),
521 ),
522 Span::raw(" "),
523 Span::styled(format!("{} done", c.done), status_style(RunStatus::Ready)),
524 Span::raw(" "),
525 Span::styled(
526 format!("{} attention", c.attention),
527 status_style(RunStatus::Blocked),
528 ),
529 Span::raw(format!(" | filter: {}", app.filter.label())),
530 ]);
531 if c.unreadable > 0 {
532 line.push_span(Span::styled(
533 format!(" | {} unreadable", c.unreadable),
534 status_style(RunStatus::Failed),
535 ));
536 }
537 frame.render_widget(Paragraph::new(line), area);
538}
539
540fn body(frame: &mut Frame, area: Rect, app: &mut App) {
541 let panes = Layout::default()
542 .direction(Direction::Horizontal)
543 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
544 .split(area);
545
546 let visible = app.visible();
547 let items: Vec<ListItem> = visible
548 .iter()
549 .map(|i| {
550 let state = &app.runs[*i].state;
551 let status = state.status.display_label().to_owned();
552 ListItem::new(Line::from(vec![
553 Span::styled(format!("{:<12}", status), status_style(state.status)),
554 Span::raw(format!(
555 "{} {}",
556 state.short(),
557 state.instruction.lines().next().unwrap_or_default()
558 )),
559 ]))
560 })
561 .collect();
562
563 let list_focused = app.focus == Focus::List;
564 let list = List::new(items)
565 .block(pane_block(" runs ", list_focused))
566 .highlight_style(
567 Style::default()
568 .bg(Color::DarkGray)
569 .add_modifier(Modifier::BOLD),
570 )
571 .highlight_symbol("> ");
572 let mut list_state = ListState::default();
573 if !visible.is_empty() {
574 list_state.select(Some(app.cursor));
575 }
576 frame.render_stateful_widget(list, panes[0], &mut list_state);
577
578 let text = app
581 .detail()
582 .into_text()
583 .unwrap_or_else(|_| app.detail().into());
584 let detail = Paragraph::new(text)
585 .block(pane_block(" report ", !list_focused))
586 .wrap(Wrap { trim: false })
587 .scroll((app.scroll, 0));
588 frame.render_widget(detail, panes[1]);
589}
590
591fn pane_block(title: &str, focused: bool) -> Block<'_> {
592 let style = if focused {
593 Style::default().fg(Color::Cyan)
594 } else {
595 Style::default().fg(Color::DarkGray)
596 };
597 Block::bordered().title(title).border_style(style)
598}
599
600fn footer(frame: &mut Frame, area: Rect, app: &App) {
601 let text = match &app.status {
602 Some(msg) => msg.clone(),
603 None => "j/k move Tab pane J/K scroll a filter r refresh o open dir ? help q quit"
604 .to_owned(),
605 };
606 frame.render_widget(
607 Paragraph::new(Span::styled(text, Style::default().fg(Color::DarkGray))),
608 area,
609 );
610}
611
612fn help_overlay(frame: &mut Frame, area: Rect) {
613 let lines = vec![
614 Line::from("magi — observation deck (read-only)"),
615 Line::from(""),
616 Line::from("j / k / ↓ / ↑ move in the focused pane"),
617 Line::from("Tab switch pane (runs / report)"),
618 Line::from("J / K scroll the report by 5"),
619 Line::from("PageDown / Up scroll the report by 20"),
620 Line::from("g / G newest / oldest run"),
621 Line::from("a cycle filter: all, active, attention, done"),
622 Line::from("r refresh now (it also refreshes every second)"),
623 Line::from("o open the run's directory in the OS file manager"),
624 Line::from("q / Esc quit"),
625 Line::from(""),
626 Line::from("Nothing here mutates a run. Use `magi fold` for cleanup."),
627 ];
628 let height = (lines.len() as u16 + 2).min(area.height);
629 let width = 66.min(area.width);
630 let popup = Rect {
631 x: area.x + (area.width.saturating_sub(width)) / 2,
632 y: area.y + (area.height.saturating_sub(height)) / 2,
633 width,
634 height,
635 };
636 frame.render_widget(ratatui::widgets::Clear, popup);
637 frame.render_widget(
638 Paragraph::new(lines).block(pane_block(" help ", true)),
639 popup,
640 );
641}
642
643struct TerminalGuard;
648
649impl TerminalGuard {
650 fn new() -> Result<Self> {
651 enable_raw_mode().context("enabling terminal raw mode")?;
652 execute!(io::stdout(), EnterAlternateScreen).context("entering alt screen")?;
653 Ok(Self)
654 }
655}
656
657impl Drop for TerminalGuard {
658 fn drop(&mut self) {
659 let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
666 let _ = disable_raw_mode();
667 }
668}
669
670pub fn run() -> Result<()> {
672 let _guard = TerminalGuard::new()?;
673 let backend = CrosstermBackend::new(io::stdout());
674 let mut terminal = Terminal::with_options(
675 backend,
676 TerminalOptions {
677 viewport: Viewport::Fullscreen,
678 },
679 )
680 .context("constructing ratatui terminal")?;
681 let mut app = App::load();
682 event_loop(&mut terminal, &mut app)
683}
684
685pub fn event_loop<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
687 while !app.quitting() {
688 terminal
689 .draw(|f| draw(f, app))
690 .map_err(|e| anyhow::anyhow!("drawing frame: {e}"))?;
691 if event::poll(TICK).context("polling for input")?
692 && let Event::Key(key) = event::read().context("reading input")?
693 {
694 app.on_key(key);
695 }
696 app.tick();
697 }
698 Ok(())
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::config::Config;
705 use crate::run::Tally;
706 use ratatui::backend::TestBackend;
707 use std::collections::BTreeMap;
708 use std::path::PathBuf;
709
710 fn state(instruction: &str, status: RunStatus) -> RunState {
711 let mut s = RunState::new(
712 PathBuf::from("/repo"),
713 "main".to_owned(),
714 "abcdef1234".to_owned(),
715 instruction.to_owned(),
716 Config::default(),
717 );
718 s.status = status;
719 s
720 }
721
722 fn app() -> App {
723 App::new(vec![
724 state("add retries", RunStatus::Reviewing),
725 state("fix the parser", RunStatus::Blocked),
726 state("document the gate", RunStatus::Merged),
727 ])
728 }
729
730 fn key(code: KeyCode) -> KeyEvent {
731 KeyEvent::new(code, KeyModifiers::NONE)
732 }
733
734 #[test]
735 fn counts_partition_every_run() {
736 let c = app().counts();
737 assert_eq!(c.total, 3);
738 assert_eq!(c.active, 1);
739 assert_eq!(c.attention, 1);
740 assert_eq!(c.done, 1);
741 assert_eq!(c.active + c.attention + c.done, c.total);
742 }
743
744 #[test]
745 fn a_verified_noop_run_counts_as_attention_not_active_or_done() {
746 let a = App::new(vec![state(
752 "already fixed elsewhere",
753 RunStatus::VerifiedNoop,
754 )]);
755 let c = a.counts();
756 assert_eq!(c.attention, 1);
757 assert_eq!(c.active, 0);
758 assert_eq!(c.done, 0);
759
760 assert!(Filter::Attention.accepts(RunStatus::VerifiedNoop));
761 assert!(!Filter::Active.accepts(RunStatus::VerifiedNoop));
762 assert!(!Filter::Done.accepts(RunStatus::VerifiedNoop));
763 }
764
765 #[test]
766 fn a_verified_noop_run_does_not_render_as_failed() {
767 let mut a = App::new(vec![state(
768 "already fixed elsewhere",
769 RunStatus::VerifiedNoop,
770 )]);
771 let mut terminal = Terminal::new(TestBackend::new(110, 30)).unwrap();
772 terminal.draw(|f| draw(f, &mut a)).unwrap();
773
774 let rendered: String = terminal
775 .backend()
776 .buffer()
777 .content()
778 .iter()
779 .map(|c| c.symbol())
780 .collect();
781 assert!(
782 rendered.contains("agent-verified no-op"),
783 "the list row must say what actually happened: {rendered}"
784 );
785 assert!(
786 !rendered.to_lowercase().contains("failed"),
787 "a verified no-op must never read as the failure it is not: {rendered}"
788 );
789 }
790
791 #[test]
797 fn an_unreadable_run_keeps_its_last_snapshot_and_is_counted() {
798 let dir = tempfile::tempdir().unwrap();
799 run::set_home(dir.path().to_path_buf());
800 if run::home() != dir.path() {
802 return;
803 }
804
805 let mut saved = state("watch me", RunStatus::Reviewing);
806 saved.save().expect("save run state");
807 let id = saved.id.clone();
808
809 let mut a = App::load();
810 assert_eq!(a.visible().len(), 1, "the saved run is listed");
811 assert_eq!(a.counts().unreadable, 0);
812
813 let path = run::run_dir(&id).join("run.json");
815 std::fs::write(&path, "{ not json").unwrap();
816 a.refresh();
817 assert_eq!(a.visible().len(), 1, "row must not blink out");
818 assert_eq!(a.selected().unwrap().instruction, "watch me");
819 assert_eq!(a.counts().unreadable, 0, "a stale snapshot is not a loss");
820
821 let fresh = App::load();
824 assert!(fresh.visible().is_empty());
825 assert_eq!(fresh.counts().unreadable, 1);
826 assert_eq!(fresh.counts().total, 0);
827 }
828
829 #[test]
830 fn cursor_wraps_in_both_directions() {
831 let mut a = app();
832 assert_eq!(a.selected().unwrap().instruction, "add retries");
833 a.next_run();
834 a.next_run();
835 assert_eq!(a.selected().unwrap().instruction, "document the gate");
836 a.next_run();
837 assert_eq!(a.selected().unwrap().instruction, "add retries");
838 a.prev_run();
839 assert_eq!(a.selected().unwrap().instruction, "document the gate");
840 }
841
842 #[test]
843 fn filter_cycles_and_narrows() {
844 let mut a = app();
845 assert_eq!(a.visible().len(), 3);
846 a.cycle_filter();
847 assert_eq!(a.filter(), Filter::Active);
848 assert_eq!(a.visible().len(), 1);
849 assert_eq!(a.selected().unwrap().instruction, "add retries");
850 a.cycle_filter();
851 assert_eq!(a.filter(), Filter::Attention);
852 assert_eq!(a.selected().unwrap().instruction, "fix the parser");
853 a.cycle_filter();
854 assert_eq!(a.filter(), Filter::Done);
855 assert_eq!(a.selected().unwrap().instruction, "document the gate");
856 a.cycle_filter();
857 assert_eq!(a.filter(), Filter::All);
858 }
859
860 #[test]
861 fn a_filter_that_hides_the_cursor_does_not_panic() {
862 let mut a = app();
863 a.last_run();
864 a.filter = Filter::Active;
865 a.clamp();
866 assert!(a.selected().is_some());
867 a.filter = Filter::Done;
868 a.cursor = 99;
869 a.clamp();
870 assert_eq!(a.cursor, 0);
871 }
872
873 #[test]
874 fn empty_state_selects_nothing_and_still_renders() {
875 let mut a = App::new(Vec::new());
876 assert!(a.selected().is_none());
877 a.next_run();
878 a.prev_run();
879 a.last_run();
880 assert_eq!(a.cursor, 0);
881 assert!(a.detail().contains("no runs yet"));
882 }
883
884 #[test]
885 fn scroll_never_goes_negative() {
886 let mut a = app();
887 a.scroll_by(-10);
888 assert_eq!(a.scroll, 0);
889 a.scroll_by(7);
890 assert_eq!(a.scroll, 7);
891 a.scroll_by(-3);
892 assert_eq!(a.scroll, 4);
893 }
894
895 #[test]
896 fn focus_routes_movement_keys() {
897 let mut a = app();
898 assert_eq!(a.focus(), Focus::List);
899 a.on_key(key(KeyCode::Char('j')));
900 assert_eq!(a.selected().unwrap().instruction, "fix the parser");
901 assert_eq!(a.scroll, 0);
902
903 a.on_key(key(KeyCode::Tab));
904 assert_eq!(a.focus(), Focus::Detail);
905 a.on_key(key(KeyCode::Char('j')));
906 assert_eq!(a.selected().unwrap().instruction, "fix the parser");
908 assert_eq!(a.scroll, 1);
909 }
910
911 #[test]
912 fn quit_keys() {
913 for code in [KeyCode::Char('q'), KeyCode::Esc] {
914 let mut a = app();
915 a.on_key(key(code));
916 assert!(a.quitting(), "{code:?} should quit");
917 }
918 let mut a = app();
919 a.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
920 assert!(a.quitting());
921 let mut a = app();
923 a.on_key(key(KeyCode::Char('c')));
924 assert!(!a.quitting());
925 }
926
927 #[test]
928 fn help_is_modal_but_never_swallows_a_quit() {
929 let mut a = app();
930 a.on_key(key(KeyCode::Char('?')));
931 assert!(a.help);
932 a.on_key(key(KeyCode::Char('j')));
933 assert!(!a.help, "any key dismisses help");
934 assert_eq!(a.selected().unwrap().instruction, "add retries");
936
937 a.on_key(key(KeyCode::Char('?')));
939 a.on_key(key(KeyCode::Char('?')));
940 assert!(!a.help);
941
942 for code in [KeyCode::Char('q'), KeyCode::Esc] {
943 let mut a = app();
944 a.on_key(key(KeyCode::Char('?')));
945 a.on_key(key(code));
946 assert!(a.quitting(), "{code:?} must quit from the help overlay");
947 }
948 let mut a = app();
949 a.on_key(key(KeyCode::Char('?')));
950 a.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
951 assert!(a.quitting(), "help must not swallow Ctrl-C");
952 }
953
954 #[test]
955 fn key_releases_are_ignored() {
956 let mut a = app();
957 let mut release = key(KeyCode::Char('q'));
958 release.kind = KeyEventKind::Release;
959 a.on_key(release);
960 assert!(!a.quitting(), "a key release must not act twice");
961 }
962
963 #[test]
964 fn frame_shows_counts_list_and_report() {
965 let mut a = app();
966 a.runs[0].state.tally = Some(Tally {
967 first_choice: BTreeMap::from([('A', 3)]),
968 borda: BTreeMap::new(),
969 winner: 'A',
970 rankings: 3,
971 unanimous_initial: true,
972 deliberated: false,
973 changed_votes: 0,
974 unanimous_final: true,
975 tie_break: None,
976 judges: 3,
977 present: 3,
978 quorum: 2,
979 met_quorum: true,
980 uncontested: None,
981 });
982 let mut terminal = Terminal::new(TestBackend::new(110, 30)).unwrap();
983 terminal.draw(|f| draw(f, &mut a)).unwrap();
984
985 let rendered: String = terminal
986 .backend()
987 .buffer()
988 .content()
989 .iter()
990 .map(|c| c.symbol())
991 .collect();
992 assert!(rendered.contains("3 runs"), "{rendered}");
993 assert!(rendered.contains("1 active"));
994 assert!(rendered.contains("1 attention"));
995 assert!(rendered.contains("reviewing"), "status word in the list");
996 assert!(rendered.contains("add retries"), "instruction in the list");
997 assert!(rendered.contains("blocked"));
998 assert!(rendered.contains("candidates"), "report pane rendered");
1000 assert!(rendered.contains("q quit"), "footer hints");
1001 }
1002
1003 #[test]
1004 fn help_overlay_renders_over_the_panes() {
1005 let mut a = app();
1006 a.on_key(key(KeyCode::Char('?')));
1007 let mut terminal = Terminal::new(TestBackend::new(110, 30)).unwrap();
1008 terminal.draw(|f| draw(f, &mut a)).unwrap();
1009 let rendered: String = terminal
1010 .backend()
1011 .buffer()
1012 .content()
1013 .iter()
1014 .map(|c| c.symbol())
1015 .collect();
1016 assert!(rendered.contains("observation deck"));
1017 assert!(rendered.contains("Nothing here mutates a run"));
1018 }
1019
1020 #[test]
1021 fn a_narrow_terminal_still_renders() {
1022 let mut a = app();
1023 let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap();
1024 terminal.draw(|f| draw(f, &mut a)).unwrap();
1025 a.on_key(key(KeyCode::Char('?')));
1026 terminal.draw(|f| draw(f, &mut a)).unwrap();
1027 }
1028}