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