pub(crate) mod keymap;
pub mod theme;
pub(crate) mod widgets;
use crossterm::event::Event;
use ratatui::Terminal;
use std::time::Duration;
pub trait EventSource {
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
}
pub struct CrosstermEventSource {
poll_fn: fn(Duration) -> std::io::Result<bool>,
read_fn: fn() -> std::io::Result<Event>,
}
impl CrosstermEventSource {
pub fn open() -> Self {
Self {
poll_fn: crossterm::event::poll,
read_fn: crossterm::event::read,
}
}
}
impl EventSource for CrosstermEventSource {
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>> {
if (self.poll_fn)(timeout)? {
Ok(Some((self.read_fn)()?))
} else {
Ok(None)
}
}
}
pub trait TerminalSetup {
type B: ratatui::backend::Backend;
fn enable(&mut self) -> anyhow::Result<()>;
fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>>;
fn disable(&mut self);
fn print_done(&self);
}
#[cfg(test)]
pub(crate) use test_doubles::*;
#[cfg(test)]
mod test_doubles {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub(crate) fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
}
pub(crate) fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, modifiers))
}
pub(crate) struct TestEventSource {
events: std::collections::VecDeque<Option<Event>>,
fail: bool,
}
impl TestEventSource {
pub(crate) fn new(events: Vec<Event>) -> Self {
Self {
events: events.into_iter().map(Some).collect(),
fail: false,
}
}
pub(crate) fn new_with_nones(events: Vec<Option<Event>>) -> Self {
Self {
events: events.into(),
fail: false,
}
}
pub(crate) fn failing() -> Self {
Self {
events: std::collections::VecDeque::new(),
fail: true,
}
}
}
impl EventSource for TestEventSource {
fn poll_event(&mut self, _timeout: Duration) -> std::io::Result<Option<Event>> {
if self.fail {
return Err(std::io::Error::other("simulated event source failure"));
}
Ok(self.events.pop_front().flatten())
}
}
pub(crate) struct TestBackendHarness {
inner: ratatui::backend::TestBackend,
fail_draw: bool,
}
impl TestBackendHarness {
pub(crate) fn new(width: u16, height: u16) -> Self {
Self {
inner: ratatui::backend::TestBackend::new(width, height),
fail_draw: false,
}
}
pub(crate) fn failing(width: u16, height: u16) -> Self {
Self {
inner: ratatui::backend::TestBackend::new(width, height),
fail_draw: true,
}
}
pub(crate) fn buffer(&self) -> &ratatui::buffer::Buffer {
self.inner.buffer()
}
pub(crate) fn text(&self) -> String {
let buffer = self.buffer();
let width = buffer.area.width as usize;
buffer
.content
.chunks(width)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
}
fn into_ok<T>(result: Result<T, std::convert::Infallible>) -> std::io::Result<T> {
match result {
Ok(value) => Ok(value),
}
}
impl ratatui::backend::Backend for TestBackendHarness {
type Error = std::io::Error;
fn draw<'a, I>(&mut self, content: I) -> std::io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
{
if self.fail_draw {
return Err(std::io::Error::other("simulated draw failure"));
}
into_ok(self.inner.draw(content))
}
fn hide_cursor(&mut self) -> std::io::Result<()> {
into_ok(self.inner.hide_cursor())
}
fn show_cursor(&mut self) -> std::io::Result<()> {
into_ok(self.inner.show_cursor())
}
fn get_cursor_position(&mut self) -> std::io::Result<ratatui::layout::Position> {
into_ok(self.inner.get_cursor_position())
}
fn set_cursor_position<P: Into<ratatui::layout::Position>>(
&mut self,
position: P,
) -> std::io::Result<()> {
into_ok(self.inner.set_cursor_position(position))
}
fn clear(&mut self) -> std::io::Result<()> {
into_ok(self.inner.clear())
}
fn clear_region(&mut self, region: ratatui::backend::ClearType) -> std::io::Result<()> {
into_ok(self.inner.clear_region(region))
}
fn size(&self) -> std::io::Result<ratatui::layout::Size> {
into_ok(self.inner.size())
}
fn window_size(&mut self) -> std::io::Result<ratatui::backend::WindowSize> {
into_ok(self.inner.window_size())
}
fn flush(&mut self) -> std::io::Result<()> {
into_ok(self.inner.flush())
}
}
pub(crate) fn test_terminal() -> Terminal<TestBackendHarness> {
Terminal::new(TestBackendHarness::new(120, 40)).unwrap()
}
pub(crate) struct TestSetup {
pub(crate) enable_should_fail: bool,
pub(crate) create_should_fail: bool,
pub(crate) draw_should_fail: bool,
}
impl TestSetup {
pub(crate) fn new() -> Self {
Self {
enable_should_fail: false,
create_should_fail: false,
draw_should_fail: false,
}
}
}
impl TerminalSetup for TestSetup {
type B = TestBackendHarness;
fn enable(&mut self) -> anyhow::Result<()> {
if self.enable_should_fail {
anyhow::bail!("simulated enable failure");
}
Ok(())
}
fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>> {
if self.create_should_fail {
anyhow::bail!("simulated create_terminal failure");
}
let backend = match self.draw_should_fail {
true => TestBackendHarness::failing(80, 24),
false => TestBackendHarness::new(80, 24),
};
Terminal::new(backend).map_err(anyhow::Error::from)
}
fn disable(&mut self) {}
fn print_done(&self) {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
fn poll_ready(_: Duration) -> std::io::Result<bool> {
Ok(true)
}
fn poll_timeout(_: Duration) -> std::io::Result<bool> {
Ok(false)
}
fn poll_fails(_: Duration) -> std::io::Result<bool> {
Err(std::io::Error::other("poll exploded"))
}
fn read_resize() -> std::io::Result<Event> {
Ok(Event::Resize(80, 24))
}
fn read_fails() -> std::io::Result<Event> {
Err(std::io::Error::other("read exploded"))
}
#[test]
fn crossterm_event_source_returns_the_read_event_when_poll_reports_ready() {
let mut source = CrosstermEventSource {
poll_fn: poll_ready,
read_fn: read_resize,
};
let event = source.poll_event(Duration::from_millis(1)).unwrap();
assert_eq!(event, Some(Event::Resize(80, 24)));
}
#[test]
fn crossterm_event_source_returns_none_when_poll_times_out() {
let mut source = CrosstermEventSource {
poll_fn: poll_timeout,
read_fn: read_resize,
};
let event = source.poll_event(Duration::from_millis(1)).unwrap();
assert!(event.is_none());
}
#[test]
fn crossterm_event_source_propagates_a_poll_error() {
let mut source = CrosstermEventSource {
poll_fn: poll_fails,
read_fn: read_resize,
};
let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
assert!(err.to_string().contains("poll exploded"));
}
#[test]
fn crossterm_event_source_propagates_a_read_error() {
let mut source = CrosstermEventSource {
poll_fn: poll_ready,
read_fn: read_fails,
};
let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
assert!(err.to_string().contains("read exploded"));
}
#[test]
fn crossterm_event_source_new_stores_the_real_crossterm_functions() {
let _source = CrosstermEventSource::open();
}
#[test]
fn test_event_source_yields_scripted_events_then_none_forever() {
let mut source = TestEventSource::new(vec![key(KeyCode::Esc)]);
assert_eq!(
source.poll_event(Duration::from_millis(1)).unwrap(),
Some(key(KeyCode::Esc))
);
assert!(
source
.poll_event(Duration::from_millis(1))
.unwrap()
.is_none()
);
assert!(
source
.poll_event(Duration::from_millis(1))
.unwrap()
.is_none()
);
}
#[test]
fn test_event_source_interleaves_explicit_timeout_ticks() {
let mut source = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Enter))]);
assert!(
source
.poll_event(Duration::from_millis(1))
.unwrap()
.is_none()
);
assert_eq!(
source.poll_event(Duration::from_millis(1)).unwrap(),
Some(key(KeyCode::Enter))
);
}
#[test]
fn test_event_source_failing_mode_errors_on_every_poll() {
let mut source = TestEventSource::failing();
assert!(source.poll_event(Duration::from_millis(1)).is_err());
assert!(source.poll_event(Duration::from_millis(1)).is_err());
}
#[test]
fn key_with_carries_its_modifiers() {
let event = key_with(KeyCode::Char('s'), crossterm::event::KeyModifiers::CONTROL);
assert_eq!(
event,
Event::Key(crossterm::event::KeyEvent::new(
KeyCode::Char('s'),
crossterm::event::KeyModifiers::CONTROL
))
);
assert_ne!(event, key(KeyCode::Char('s')));
}
#[test]
fn test_backend_harness_draws_or_fails_on_demand() {
use ratatui::backend::Backend;
let mut ok = TestBackendHarness::new(10, 3);
assert!(ok.draw(std::iter::empty()).is_ok());
assert!(ok.hide_cursor().is_ok());
assert!(ok.show_cursor().is_ok());
assert!(ok.get_cursor_position().is_ok());
assert!(
ok.set_cursor_position(ratatui::layout::Position::new(0, 0))
.is_ok()
);
assert!(ok.clear().is_ok());
assert!(ok.clear_region(ratatui::backend::ClearType::All).is_ok());
assert!(ok.size().is_ok());
assert!(ok.window_size().is_ok());
assert!(ok.flush().is_ok());
let mut bad = TestBackendHarness::failing(10, 3);
assert!(bad.draw(std::iter::empty()).is_err());
}
#[test]
fn test_terminal_is_ready_to_draw() {
let mut terminal = test_terminal();
assert!(terminal.draw(|_| {}).is_ok());
}
#[test]
fn test_setup_succeeds_by_default_and_fails_when_switched() {
let mut setup = TestSetup::new();
assert!(setup.enable().is_ok());
assert!(setup.create_terminal().is_ok());
setup.disable();
setup.print_done();
let mut enable_fails = TestSetup {
enable_should_fail: true,
create_should_fail: false,
draw_should_fail: false,
};
assert!(enable_fails.enable().is_err());
let mut create_fails = TestSetup {
enable_should_fail: false,
create_should_fail: true,
draw_should_fail: false,
};
assert!(create_fails.create_terminal().is_err());
}
}