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