diff --git a/src/event_source.rs b/src/event_source.rs new file mode 100644 index 0000000..451a6f6 --- /dev/null +++ b/src/event_source.rs @@ -0,0 +1,27 @@ +use crossterm::event::{self, Event}; +use std::error::Error; +use std::time::Duration; + +/// An abstraction around event sourcing so we can inject events in tests. +pub trait EventSource { + /// Polls for an event for up to `timeout`, returning `Ok(true)` if an event is + /// available, `Ok(false)` if the timeout elapsed without an event, or an `Err` + /// if polling failed. + fn poll(&mut self, timeout: Duration) -> Result>; + + /// Reads the next available event, or returns an `Err` if reading fails. + fn read(&mut self) -> Result>; +} + +/// Production implementation that delegates to `crossterm::event`. +pub struct CrosstermEventSource; + +impl EventSource for CrosstermEventSource { + fn poll(&mut self, timeout: Duration) -> Result> { + Ok(event::poll(timeout).map_err(Box::new)?) + } + + fn read(&mut self) -> Result> { + Ok(event::read().map_err(Box::new)?) + } +} diff --git a/src/main.rs b/src/main.rs index e88c46c..470746d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod event_source; mod terminal_guard; /// UI components (todo list) mod todo_list; @@ -6,7 +7,9 @@ use core::time::Duration; use crossterm::event::{self, EnableMouseCapture, Event, KeyCode}; use crossterm::execute; use crossterm::terminal::{EnterAlternateScreen, enable_raw_mode}; +use event_source::{CrosstermEventSource, EventSource}; use ratatui::Terminal; +use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use std::io::{self, Read as _}; use terminal_guard::TerminalModeGuard; @@ -37,13 +40,30 @@ fn main() -> Result<(), Box> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + run_app(&mut terminal, &mut CrosstermEventSource, &mut todo)?; + + drop(mode_guard); + terminal.show_cursor()?; + + Ok(()) +} + +/// Run the app loop using an abstract `EventSource` so tests can inject events. +pub fn run_app( + terminal: &mut Terminal, + event_source: &mut E, + todo: &mut TodoList, +) -> Result<(), Box> +where + ::Error: 'static, +{ loop { terminal.draw(|f| { todo.draw(f); })?; - if event::poll(Duration::from_millis(100))? { - match event::read()? { + if event_source.poll(Duration::from_millis(100))? { + match event_source.read()? { Event::Key(key) => match key.code { KeyCode::Char('q') => break, KeyCode::Up => { @@ -93,8 +113,63 @@ fn main() -> Result<(), Box> { } } - drop(mode_guard); - terminal.show_cursor()?; - Ok(()) } + +#[cfg(test)] +mod tests { + #![allow(clippy::panic_in_result_fn)] + use super::*; + use crate::todo_list::TodoList; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::collections::VecDeque; + use std::time::Duration; + + struct MockEventSource { + events: VecDeque, + } + + impl MockEventSource { + fn new(events: Vec) -> Self { + Self { + events: VecDeque::from(events), + } + } + } + + impl crate::event_source::EventSource for MockEventSource { + fn poll(&mut self, _timeout: Duration) -> Result> { + Ok(!self.events.is_empty()) + } + + fn read(&mut self) -> Result> { + Ok(self.events.pop_front().expect("no events left")) + } + } + + #[test] + fn pressing_down_moves_selection() -> Result<(), Box> { + let lines = vec!["one".to_owned(), "two".to_owned(), "three".to_owned()]; + let mut todo = TodoList::with_lines(lines); + + // prepare events: Down then 'q' to exit + let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let quit = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + let mut source = MockEventSource::new(vec![down, quit]); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend)?; + + // initially selection is Some(0) + assert_eq!(todo.state.selected(), Some(0)); + + run_app(&mut terminal, &mut source, &mut todo)?; + + // after pressing Down we expect selection to move to 1 + assert_eq!(todo.state.selected(), Some(1)); + + Ok(()) + } +} diff --git a/src/todo_list.rs b/src/todo_list.rs index 7951350..4d601db 100644 --- a/src/todo_list.rs +++ b/src/todo_list.rs @@ -4,35 +4,17 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; /// A simple todo/list view that owns the lines, marked flags and selection state. pub struct TodoList { + /// The text lines in the todo list. pub lines: Vec, + /// Flags indicating whether each corresponding line is marked. pub marked: Vec, + /// Stateful selection for the list widget. pub state: ListState, + /// Title displayed above the list widget. pub title: String, } impl TodoList { - /// Create a new `TodoList` from lines. - pub fn new(lines: Vec) -> Self { - let mut state = ListState::default(); - if !lines.is_empty() { - state.select(Some(0)); - } - - Self { - lines, - marked: vec![false; state.selected().map(|_| 0).unwrap_or(0)], - state, - title: "Lines (space to mark, q to quit)".to_string(), - } - } - - /// Convenience constructor that ensures marked has same length as lines. - pub fn with_lines(lines: Vec) -> Self { - let mut s = Self::new(lines); - s.marked = vec![false; s.lines.len()]; - s - } - /// Draw the list into a `ratatui` frame. pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { let size = f.area(); @@ -53,4 +35,26 @@ impl TodoList { f.render_stateful_widget(list, size, &mut self.state); } + + /// Create a new `TodoList` from lines. + pub fn new(lines: Vec) -> Self { + let mut state = ListState::default(); + if !lines.is_empty() { + state.select(Some(0)); + } + + Self { + lines, + marked: vec![false; state.selected().map_or(0, |_| 0)], + state, + title: "Lines (space to mark, q to quit)".to_owned(), + } + } + + /// Convenience constructor that ensures marked has same length as lines. + pub fn with_lines(lines: Vec) -> Self { + let mut s = Self::new(lines); + s.marked = vec![false; s.lines.len()]; + s + } } diff --git a/tests/ui_snapshot.rs b/tests/ui_snapshot.rs index be8acdf..6a21c6f 100644 --- a/tests/ui_snapshot.rs +++ b/tests/ui_snapshot.rs @@ -12,7 +12,7 @@ fn render_list_into_buffer( width: u16, height: u16, ) -> Buffer { - let backend = TestBackend::new(width.into(), height.into()); + let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); let lines_vec: Vec = lines @@ -45,8 +45,8 @@ fn render_list_into_buffer( // Extract the underlying backend buffer (clone to own it) let backend = terminal.backend(); - let buf = backend.buffer().clone(); - buf + + backend.buffer().clone() } #[test]