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};
use ratatui_crossterm::CrosstermBackend;
use tokio::time::{MissedTickBehavior, interval};
use tokio_stream::{Stream, StreamExt};
use crate::screen::{Scrollback, close_footer, pin_footer};
use crate::{Element, Event, RunnerConfig, TerminalSession, Theme, paint, translate_event};
#[derive(Clone, Debug)]
pub enum Signal {
Tick,
Event(Event),
}
pub struct AsyncRunner {
config: RunnerConfig,
scrollback: Scrollback,
}
impl AsyncRunner {
pub fn new(mut config: RunnerConfig) -> Self {
config.tick_rate = config.tick_rate.max(Duration::from_millis(1));
Self {
config,
scrollback: Scrollback::new(),
}
}
pub fn scrollback(&self) -> Scrollback {
self.scrollback.clone()
}
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 mode = self.config.screen_mode;
let _session = TerminalSession::enter_with(mode)?;
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: mode.viewport(),
},
)?;
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;
if mode.is_alternate() {
let _ = terminal.clear();
} else {
let _ = close_footer(&mut terminal);
}
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 split = !self.config.screen_mode.is_alternate();
let mut ticker = interval(self.config.tick_rate);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
if split {
pin_footer(terminal)?;
}
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;
}
if split {
self.scrollback.flush(terminal, theme)?;
terminal.autoresize()?;
pin_footer(terminal)?;
} else {
self.scrollback.clear();
}
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,
..RunnerConfig::default()
});
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),
..RunnerConfig::default()
});
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),
..RunnerConfig::default()
});
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 split_footer_publishes_above_a_pinned_footer() {
use crate::screen::ScreenMode;
use ratatui_core::layout::Position;
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
screen_mode: ScreenMode::split_footer(2),
});
let scrollback = runner.scrollback();
let mut backend = TestBackend::new(12, 6);
backend
.set_cursor_position(Position::new(0, 0))
.expect("place cursor");
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: runner.config.screen_mode.viewport(),
},
)
.expect("inline terminal");
let events = tokio_stream::iter([key(KeyCode::Char('a')), key(KeyCode::Char('q'))]);
let mut state = ();
runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut state,
events,
|_state, _frame| {
element(Text::new(vec![
ratatui_core::text::Line::from("FOOTER"),
ratatui_core::text::Line::from("FOOTER"),
]))
},
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Char('q') => {
ControlFlow::Break(())
}
Signal::Event(_) => {
scrollback.write(|_width| element(Text::raw("published")));
ControlFlow::Continue(())
}
_ => ControlFlow::Continue(()),
},
)
.await
.expect("run");
let buffer = terminal.backend().buffer();
let lines: Vec<String> = (0..6)
.map(|y| crate::tests::support::row(buffer, y))
.collect();
assert_eq!(
&lines[3..],
&["published", "FOOTER", "FOOTER"],
"the block sits directly above the repainted footer: {lines:?}"
);
}
#[tokio::test]
async fn a_background_task_publishes_while_the_loop_runs() {
use crate::screen::ScreenMode;
use ratatui_core::layout::Position;
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_millis(5),
screen_mode: ScreenMode::split_footer(2),
});
let scrollback = runner.scrollback();
let mut backend = TestBackend::new(14, 8);
backend
.set_cursor_position(Position::new(0, 0))
.expect("place cursor");
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: runner.config.screen_mode.viewport(),
},
)
.expect("inline terminal");
let producer = scrollback.clone();
tokio::spawn(async move {
for i in 0..3u32 {
producer.write(move |_width| element(Text::raw(format!("task-{i}"))));
tokio::task::yield_now().await;
}
});
let mut ticks = 0u32;
runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut ticks,
tokio_stream::iter(Vec::<Result<Event, Infallible>>::new()),
|_ticks, _frame| element(Text::raw("FOOTER")),
async |ticks, _signal| {
*ticks += 1;
if *ticks >= 8 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.await
.expect("run");
let buffer = terminal.backend().buffer();
let lines: Vec<String> = (0..8)
.map(|y| crate::tests::support::row(buffer, y))
.collect();
for i in 0..3 {
assert!(
lines.contains(&format!("task-{i}")),
"block {i} from the background task reached the scrollback: {lines:?}"
);
}
assert!(scrollback.is_empty(), "the queue drains as the loop runs");
}
#[tokio::test]
async fn alternate_screen_discards_queued_blocks() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let scrollback = runner.scrollback();
scrollback.write(|_width| element(Text::raw("dropped")));
let mut terminal = terminal(16, 2);
let events = tokio_stream::iter([key(KeyCode::Char('a')), key(KeyCode::Esc)]);
let mut state = ();
runner
.run_with_events(
&mut terminal,
&Theme::default(),
&mut state,
events,
|_state, _frame| element(Text::raw("frame")),
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
ControlFlow::Break(())
}
_ => ControlFlow::Continue(()),
},
)
.await
.expect("run");
assert!(scrollback.is_empty(), "the queue is drained, not retained");
assert!(!buffer_text(&terminal).contains("dropped"));
}
#[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),
..RunnerConfig::default()
});
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::<u8>::new()),
TerminalOptions {
viewport: ratatui_core::terminal::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");
}
}