use std::io;
use std::ops::ControlFlow;
use std::time::Duration;
use crossterm::event::EventStream;
use ratatui_core::backend::Backend;
use ratatui_core::terminal::{Terminal, TerminalOptions, Viewport};
use ratatui_crossterm::CrosstermBackend;
use tokio::time::{MissedTickBehavior, interval};
use tokio_stream::{Stream, StreamExt};
use crate::{Element, Event, RunnerConfig, TerminalSession, Theme, paint, translate_event};
#[derive(Clone, Debug)]
pub enum Signal {
Tick,
Event(Event),
}
pub struct AsyncRunner {
config: RunnerConfig,
}
impl AsyncRunner {
pub fn new(mut config: RunnerConfig) -> Self {
config.tick_rate = config.tick_rate.max(Duration::from_millis(1));
Self { config }
}
pub async fn run<S, V, U>(
&self,
theme: &Theme,
state: &mut S,
view: V,
update: U,
) -> io::Result<()>
where
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal) -> ControlFlow<()>,
{
self.run_with_backend(
theme,
CrosstermBackend::new(io::stdout()),
state,
view,
update,
)
.await
}
pub async fn run_with_backend<S, B, V, U>(
&self,
theme: &Theme,
backend: B,
state: &mut S,
view: V,
update: U,
) -> io::Result<()>
where
B: Backend<Error = io::Error>,
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal) -> ControlFlow<()>,
{
let _session = TerminalSession::enter()?;
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)?;
let events = EventStream::new().filter_map(|result| match result {
Ok(raw) => translate_event(raw).map(Ok),
Err(error) => Some(Err(error)),
});
let result = self
.run_with_events(&mut terminal, theme, state, events, view, update)
.await;
let _ = terminal.clear();
result
}
pub async fn run_with_events<S, B, V, U, E, Er>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
state: &mut S,
mut events: E,
mut view: V,
mut update: U,
) -> Result<(), Er>
where
B: Backend<Error = Er>,
E: Stream<Item = Result<Event, Er>> + Unpin,
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal) -> ControlFlow<()>,
{
let mut events_done = false;
let mut frame = 0u64;
let mut ticker = interval(self.config.tick_rate);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
draw(terminal, theme, &mut view, state, &mut frame)?;
loop {
let signal = tokio::select! {
_ = ticker.tick() => Signal::Tick,
item = events.next(), if !events_done => match item {
Some(Ok(event)) => Signal::Event(event),
Some(Err(error)) => return Err(error),
None => {
events_done = true;
continue;
}
},
};
if update(state, signal).await.is_break() {
break;
}
draw(terminal, theme, &mut view, state, &mut frame)?;
}
Ok(())
}
}
fn draw<S, B, V, Er>(
terminal: &mut Terminal<B>,
theme: &Theme,
view: &mut V,
state: &S,
frame: &mut u64,
) -> Result<(), Er>
where
B: Backend<Error = Er>,
V: FnMut(&S, u64) -> Element,
{
terminal.draw(|terminal_frame| {
let area = terminal_frame.area();
let root = view(state, *frame);
paint(terminal_frame.buffer_mut(), area, theme, root.as_ref(), &[]);
})?;
*frame = frame.wrapping_add(1);
Ok(())
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use super::*;
use crate::components::Text;
use crate::event::{Key, KeyCode};
use crate::view::element;
use ratatui_core::backend::TestBackend;
fn key(code: KeyCode) -> Result<Event, Infallible> {
Ok(Event::Key(Key {
code,
ctrl: false,
alt: false,
shift: false,
}))
}
fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(width, height)).expect("test terminal")
}
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn zero_tick_rate_is_clamped() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::ZERO,
});
assert_eq!(runner.config.tick_rate, Duration::from_millis(1));
}
#[tokio::test]
async fn events_drive_state_and_quit_breaks() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
});
let mut terminal = terminal(24, 1);
let mut count = 0u64;
let events = tokio_stream::iter([
key(KeyCode::Char('a')),
key(KeyCode::Char('a')),
key(KeyCode::Char('q')),
]);
let result = runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut count,
events,
|count, _frame| element(Text::raw(format!("count={count}"))),
async |count, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Char('q') => {
ControlFlow::Break(())
}
Signal::Event(Event::Key(_)) => {
*count += 1;
ControlFlow::Continue(())
}
_ => ControlFlow::Continue(()),
},
)
.await;
assert!(result.is_ok());
assert_eq!(count, 2, "both 'a' presses counted, 'q' quit");
assert!(
buffer_text(&terminal).contains("count=2"),
"final frame reflects state: {:?}",
buffer_text(&terminal)
);
}
#[tokio::test]
async fn initial_frame_paints_before_first_signal() {
let runner = AsyncRunner::new(RunnerConfig::default());
let mut terminal = terminal(16, 1);
let mut state = "hello";
let events = tokio_stream::iter([key(KeyCode::Esc)]);
runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut state,
events,
|state, _frame| element(Text::raw(*state)),
async |_state, _signal| ControlFlow::Break(()),
)
.await
.unwrap();
assert!(buffer_text(&terminal).contains("hello"));
}
#[tokio::test(start_paused = true)]
async fn ticks_fire_and_can_await() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_millis(50),
});
let mut terminal = terminal(20, 1);
let mut ticks = 0u64;
let events = tokio_stream::iter(Vec::<Result<Event, Infallible>>::new());
runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut ticks,
events,
|ticks, _frame| element(Text::raw(format!("ticks={ticks}"))),
async |ticks, signal| {
if let Signal::Tick = signal {
tokio::task::yield_now().await;
*ticks += 1;
}
if *ticks >= 3 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.await
.unwrap();
assert_eq!(ticks, 3);
assert!(
buffer_text(&terminal).contains("ticks=2"),
"last painted frame: {:?}",
buffer_text(&terminal)
);
}
#[tokio::test]
async fn stream_error_propagates() {
use ratatui_core::layout::Rect;
use ratatui_crossterm::CrosstermBackend;
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
});
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::<u8>::new()),
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, 10, 1)),
},
)
.expect("test terminal");
let mut state = ();
let events = tokio_stream::iter([Err::<Event, io::Error>(io::Error::other("boom"))]);
let result = runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut state,
events,
|_state, _frame| element(Text::raw("x")),
async |_state, _signal| ControlFlow::Continue(()),
)
.await;
let error = result.expect_err("stream error should propagate");
assert_eq!(error.to_string(), "boom");
}
}