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