use std::convert::Infallible;
use std::io;
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::{Instant as TokioInstant, MissedTickBehavior, interval, sleep_until};
use tokio_stream::{Stream, StreamExt};
use super::{
FrameGraphics, FrameGraphicsCleanup, PaintRoot, RESIZE_FRAME_INTERVAL, RunnerAction,
RunnerCore, RunnerSelection, Signal,
};
use crate::live::RedrawHandle;
use crate::screen::{Scrollback, close_footer, pin_footer};
use crate::view::ScopedElement;
use crate::{
Element, Event, RenderCtx, RunnerConfig, SystemClock, TerminalSession, Theme, UpdateResult,
paint_with_context, translate_event,
};
#[deprecated(since = "0.9.0", note = "use `Signal<M>`, which now carries messages")]
pub type AsyncSignal<M> = Signal<M>;
pub trait AsyncApplication<M = Infallible> {
fn update(&mut self, signal: Signal<M>) -> impl Future<Output = UpdateResult>;
fn view(&self, frame: u64) -> ScopedElement<'_>;
}
pub trait AsyncFrameSource<M = Infallible> {
fn update(&mut self, signal: Signal<M>) -> impl Future<Output = UpdateResult>;
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>);
}
impl<M, A: AsyncApplication<M>> AsyncFrameSource<M> for &mut A {
async fn update(&mut self, signal: Signal<M>) -> UpdateResult {
AsyncApplication::update(*self, signal).await
}
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>) {
paint(AsyncApplication::view(*self, frame).as_ref());
}
}
pub struct AsyncFromFn<'state, S, V, U> {
state: &'state mut S,
view: V,
update: U,
}
pub fn async_from_fn<S, V, U, M>(state: &mut S, view: V, update: U) -> AsyncFromFn<'_, S, V, U>
where
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal<M>) -> UpdateResult,
{
AsyncFromFn {
state,
view,
update,
}
}
impl<M, S, V, U> AsyncFrameSource<M> for AsyncFromFn<'_, S, V, U>
where
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal<M>) -> UpdateResult,
{
async fn update(&mut self, signal: Signal<M>) -> UpdateResult {
(self.update)(&mut *self.state, signal).await
}
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>) {
paint((self.view)(self.state, frame).as_ref());
}
}
pub struct AsyncRunner {
config: RunnerConfig,
redraw: RedrawHandle,
scrollback: Scrollback,
session_config: Option<crate::TerminalSessionConfig>,
text_selection: bool,
}
impl AsyncRunner {
pub fn new(mut config: RunnerConfig) -> Self {
config.tick_rate = config.tick_rate.max(Duration::from_millis(1));
Self {
config,
redraw: RedrawHandle::default(),
scrollback: Scrollback::new(),
session_config: None,
text_selection: true,
}
}
pub fn redraw_handle(&self) -> RedrawHandle {
self.redraw.clone()
}
pub fn with_session_config(mut self, config: crate::TerminalSessionConfig) -> Self {
self.config.screen_mode = config.screen_mode;
self.session_config = Some(config);
self
}
pub fn with_text_selection(mut self, enabled: bool) -> Self {
self.text_selection = enabled;
self
}
fn selects_text(&self) -> bool {
self.text_selection
&& self.session_config.map_or_else(
|| self.config.screen_mode.captures_mouse(),
crate::TerminalSessionConfig::captures_mouse,
)
}
pub fn scrollback(&self) -> Scrollback {
self.scrollback.clone()
}
pub async fn run<F: AsyncFrameSource>(&self, theme: &Theme, frames: F) -> io::Result<()> {
self.run_with_messages(theme, frames, no_messages()).await
}
pub async fn run_with_messages<M, F, MS>(
&self,
theme: &Theme,
mut frames: F,
messages: MS,
) -> io::Result<()>
where
F: AsyncFrameSource<M>,
MS: Stream<Item = io::Result<M>> + Unpin,
{
let graphics = FrameGraphics::detected();
let mode = self.config.screen_mode;
let _session = if let Some(config) = self.session_config {
TerminalSession::enter_config(config)?
} else {
TerminalSession::enter_with(mode)?
};
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: mode.viewport(),
},
)?;
let _graphics_cleanup = FrameGraphicsCleanup(&graphics);
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_inner(
&mut terminal,
theme,
&mut frames,
events,
messages,
Some(&graphics),
|copied| {
graphics.finish_frame()?;
if let Some(text) = copied {
let _ = crate::term::clipboard::write(&mut io::stdout(), text)?;
}
Ok(())
},
)
.await;
if mode.is_alternate() {
let _ = terminal.clear();
} else {
let _ = close_footer(&mut terminal);
}
result
}
pub async fn run_with_backend<F, B>(
&self,
theme: &Theme,
backend: B,
mut frames: F,
) -> io::Result<()>
where
F: AsyncFrameSource,
B: Backend<Error = io::Error>,
{
let mode = self.config.screen_mode;
let _session = if let Some(config) = self.session_config {
TerminalSession::enter_config(config)?
} else {
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_inner(
&mut terminal,
theme,
&mut frames,
events,
no_messages(),
None,
|_| Ok(()),
)
.await;
if mode.is_alternate() {
let _ = terminal.clear();
} else {
let _ = close_footer(&mut terminal);
}
result
}
pub async fn run_driven_by<M, F, B, E, MS, Er>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
mut frames: F,
events: E,
messages: MS,
) -> Result<(), Er>
where
F: AsyncFrameSource<M>,
B: Backend<Error = Er>,
E: Stream<Item = Result<Event, Er>> + Unpin,
MS: Stream<Item = Result<M, Er>> + Unpin,
{
self.run_inner(terminal, theme, &mut frames, events, messages, None, |_| {
Ok(())
})
.await
}
#[deprecated(
since = "0.9.0",
note = "use `run_driven_by(terminal, theme, async_from_fn(state, view, update), events, no_messages())`"
)]
pub async fn run_with_events<S, B, V, U, E, Er>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
state: &mut S,
events: E,
view: V,
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) -> UpdateResult,
{
self.run_driven_by(
terminal,
theme,
async_from_fn(state, view, update),
events,
no_messages(),
)
.await
}
#[deprecated(
since = "0.9.0",
note = "use `run_driven_by(terminal, theme, async_from_fn(state, view, update), events, messages)`"
)]
#[allow(clippy::too_many_arguments)]
pub async fn run_with_events_and_messages<S, M, B, V, U, E, MS, Er>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
state: &mut S,
events: E,
messages: MS,
view: V,
update: U,
) -> Result<(), Er>
where
B: Backend<Error = Er>,
E: Stream<Item = Result<Event, Er>> + Unpin,
MS: Stream<Item = Result<M, Er>> + Unpin,
V: FnMut(&S, u64) -> Element,
U: AsyncFnMut(&mut S, Signal<M>) -> UpdateResult,
{
self.run_driven_by(
terminal,
theme,
async_from_fn(state, view, update),
events,
messages,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_inner<M, S, B, E, MS, Er, Finish>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
frames: &mut S,
mut events: E,
mut messages: MS,
graphics: Option<&FrameGraphics>,
mut finish_frame: Finish,
) -> Result<(), Er>
where
S: AsyncFrameSource<M>,
B: Backend<Error = Er>,
E: Stream<Item = Result<Event, Er>> + Unpin,
MS: Stream<Item = Result<M, Er>> + Unpin,
Finish: FnMut(Option<&str>) -> Result<(), Er>,
{
let mut events_done = false;
let mut messages_done = false;
let mut core = RunnerCore::new();
let mut selection = RunnerSelection::new(self.selects_text());
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)?;
}
if let RunnerAction::Render(frame) = core.next_action() {
let copied = draw(terminal, theme, frames, frame, graphics, &mut selection)?;
finish_frame(copied.as_deref())?;
}
let mut last_frame = TokioInstant::now();
let mut redraw_at = None;
loop {
enum Wake<M> {
Tick,
Event(Event),
Message(M),
Redraw,
Requested,
}
let deadline = redraw_at.unwrap_or_else(TokioInstant::now);
let wake = tokio::select! {
_ = sleep_until(deadline), if redraw_at.is_some() => Wake::Redraw,
() = self.redraw.requested() => Wake::Requested,
_ = ticker.tick() => Wake::Tick,
item = events.next(), if !events_done => match item {
Some(Ok(event)) => Wake::Event(event),
Some(Err(error)) => return Err(error),
None => {
events_done = true;
continue;
}
},
item = messages.next(), if !messages_done => match item {
Some(Ok(message)) => Wake::Message(message),
Some(Err(error)) => return Err(error),
None => {
messages_done = true;
continue;
}
},
};
if matches!(&wake, Wake::Requested) {
core.request_redraw();
let now = TokioInstant::now();
redraw_at = Some(redraw_at.map_or(now, |current: TokioInstant| current.min(now)));
continue;
}
if matches!(&wake, Wake::Redraw) {
if let RunnerAction::Render(frame) = core.next_action() {
if split {
terminal.autoresize()?;
pin_footer(terminal)?;
}
let copied = draw(terminal, theme, frames, frame, graphics, &mut selection)?;
finish_frame(copied.as_deref())?;
last_frame = TokioInstant::now();
}
redraw_at = None;
continue;
}
let (signal, selection_event) = match wake {
Wake::Tick => (Signal::Tick, None),
Wake::Event(event) => {
let selection = Some(event.clone());
(Signal::Event(event), selection)
}
Wake::Message(message) => (Signal::Message(message), None),
Wake::Redraw | Wake::Requested => unreachable!("handled above"),
};
let requires_redraw = signal.requires_redraw();
let result = frames.update(signal).await;
core.apply(result);
if core.is_exited() {
break;
}
let selection_changed = selection_event
.is_some_and(|event| selection.handle_event(&event, result, &SystemClock));
if requires_redraw || result == UpdateResult::Dirty || selection_changed {
core.request_redraw();
let now = TokioInstant::now();
let deadline = if requires_redraw {
(last_frame + RESIZE_FRAME_INTERVAL).max(now)
} else {
now
};
redraw_at =
Some(redraw_at.map_or(deadline, |current: TokioInstant| current.min(deadline)));
}
if split {
if self.scrollback.flush(terminal, theme)? {
core.request_redraw();
let now = TokioInstant::now();
redraw_at = Some(redraw_at.map_or(now, |current| current.min(now)));
}
} else {
self.scrollback.clear();
}
if redraw_at.is_some_and(|deadline| deadline <= TokioInstant::now()) {
if let RunnerAction::Render(frame) = core.next_action() {
if split {
terminal.autoresize()?;
pin_footer(terminal)?;
}
let copied = draw(terminal, theme, frames, frame, graphics, &mut selection)?;
finish_frame(copied.as_deref())?;
last_frame = TokioInstant::now();
}
redraw_at = None;
}
}
Ok(())
}
}
fn draw<M, S, B, Er>(
terminal: &mut Terminal<B>,
theme: &Theme,
frames: &mut S,
frame: u64,
graphics: Option<&FrameGraphics>,
selection: &mut RunnerSelection,
) -> Result<Option<String>, Er>
where
B: Backend<Error = Er>,
S: AsyncFrameSource<M>,
{
let mut copied = None;
terminal.draw(|terminal_frame| {
let area = terminal_frame.area();
let ctx = graphics.map_or_else(|| RenderCtx::new(theme), |g| g.render_context(theme));
frames.frame(frame, &mut |root| {
paint_with_context(terminal_frame.buffer_mut(), area, &ctx, root, &[]);
});
copied = selection.finish_frame(terminal_frame.buffer_mut(), area, theme);
})?;
Ok(copied)
}
pub fn no_messages<Er>() -> impl Stream<Item = Result<Infallible, Er>> + Unpin {
tokio_stream::empty()
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use super::*;
use crate::components::Text;
use crate::event::{Key, KeyCode, Mouse, MouseButton, MouseKind};
use crate::view::element;
use ratatui_core::backend::TestBackend;
use ratatui_core::layout::Rect;
use crate::View;
use crate::geometry::Size;
use crate::surface::Surface;
fn key(code: KeyCode) -> Result<Event, Infallible> {
Ok(Event::Key(Key {
code,
ctrl: false,
alt: false,
shift: false,
}))
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Result<Event, Infallible> {
Ok(Event::Mouse(Mouse::at(kind, column, row)))
}
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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut count,
|count, _frame| element(Text::raw(format!("count={count}"))),
async |count, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Char('q') => {
UpdateResult::Exit
}
Signal::Event(Event::Key(_)) => {
*count += 1;
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.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 clean_updates_do_not_rebuild_or_repaint() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(16, 1);
let mut state = 0u8;
let views = std::cell::Cell::new(0usize);
let events = tokio_stream::iter([key(KeyCode::Char('a')), key(KeyCode::Esc)]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|state, _frame| {
views.set(views.get() + 1);
element(Text::raw(format!("state={state}")))
},
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.await
.unwrap();
assert_eq!(
views.get(),
1,
"clean input leaves the initial frame intact"
);
}
#[tokio::test]
async fn clean_mouse_drag_uses_default_text_selection() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(11, 1);
let mut mouse_events = 0usize;
let events = tokio_stream::iter([
mouse(MouseKind::Down(MouseButton::Left), 0, 0),
mouse(MouseKind::Drag(MouseButton::Left), 4, 0),
key(KeyCode::Esc),
]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut mouse_events,
|_state, _frame| element(Text::raw("hello world")),
async |mouse_events, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
Signal::Event(Event::Mouse(_)) => {
*mouse_events += 1;
UpdateResult::Clean
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.await
.unwrap();
assert_eq!(
mouse_events, 2,
"selection does not hide events from the app"
);
let buffer = terminal.backend().buffer();
for column in 0..=4 {
assert_eq!(
buffer[(column, 0)].bg,
Theme::default().selection_bg,
"dragged cell {column} is highlighted"
);
}
}
#[tokio::test]
async fn consumed_mouse_drag_claims_the_gesture() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(11, 1);
let mut state = ();
let events = tokio_stream::iter([
mouse(MouseKind::Down(MouseButton::Left), 0, 0),
mouse(MouseKind::Drag(MouseButton::Left), 4, 0),
key(KeyCode::Esc),
]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_state, _frame| element(Text::raw("hello world")),
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
Signal::Event(Event::Mouse(_)) => UpdateResult::Consumed,
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.await
.unwrap();
let buffer = terminal.backend().buffer();
assert!(
(0..=4).all(|column| buffer[(column, 0)].bg != Theme::default().selection_bg),
"claimed drag is left entirely to the application"
);
}
#[tokio::test]
async fn mouse_wheel_still_drives_application_scrolling() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(12, 1);
let mut scrolled = false;
let events = tokio_stream::iter([mouse(MouseKind::ScrollDown, 0, 0), key(KeyCode::Esc)]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut scrolled,
|scrolled, _frame| {
element(Text::raw(if *scrolled { "line two" } else { "line one" }))
},
async |scrolled, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
Signal::Event(Event::Mouse(Mouse {
kind: MouseKind::ScrollDown,
..
})) => {
*scrolled = true;
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.await
.unwrap();
assert!(buffer_text(&terminal).contains("line two"));
}
#[tokio::test(start_paused = true)]
async fn resize_bursts_share_one_repaint() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_millis(20),
..RunnerConfig::default()
});
let mut terminal = terminal(16, 1);
let mut state = ();
let views = std::cell::Cell::new(0usize);
let events = tokio_stream::iter((0..3).map(|_| {
Ok(Event::Resize {
width: 16,
height: 1,
})
}));
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_state, _frame| {
views.set(views.get() + 1);
element(Text::raw("frame"))
},
async |_state, signal| match signal {
Signal::Tick if views.get() >= 2 => UpdateResult::Exit,
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.await
.unwrap();
assert_eq!(
views.get(),
2,
"three queued resizes share one frame after the initial paint"
);
}
#[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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|state, _frame| element(Text::raw(*state)),
async |_state, _signal| UpdateResult::Exit,
),
events,
no_messages(),
)
.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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut ticks,
|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 {
UpdateResult::Exit
} else {
UpdateResult::Dirty
}
},
),
events,
no_messages(),
)
.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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_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') => {
UpdateResult::Exit
}
Signal::Event(_) => {
scrollback.write(|_width| element(Text::raw("published")));
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut ticks,
|_ticks, _frame| element(Text::raw("FOOTER")),
async |ticks, _signal| {
*ticks += 1;
if *ticks >= 8 {
UpdateResult::Exit
} else {
UpdateResult::Clean
}
},
),
tokio_stream::iter(Vec::<Result<Event, Infallible>>::new()),
no_messages(),
)
.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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_state, _frame| element(Text::raw("frame")),
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
events,
no_messages(),
)
.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_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_state, _frame| element(Text::raw("x")),
async |_state, _signal| UpdateResult::Dirty,
),
events,
no_messages(),
)
.await;
let error = result.expect_err("stream error should propagate");
assert_eq!(error.to_string(), "boom");
}
#[tokio::test]
async fn terminal_events_and_external_messages_share_one_loop() {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Message {
Add(u64),
}
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'))]);
let messages = tokio_stream::iter([Ok::<_, Infallible>(Message::Add(7))]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut count,
|count, _| element(Text::raw(format!("count={count}"))),
async |count, signal| {
let done = |count: u64| {
if count == 8 {
UpdateResult::Exit
} else {
UpdateResult::Dirty
}
};
match signal {
Signal::Event(Event::Key(_)) => {
*count += 1;
done(*count)
}
Signal::Message(Message::Add(value)) => {
*count += value;
done(*count)
}
Signal::Tick | Signal::Event(_) => UpdateResult::Clean,
}
},
),
events,
messages,
)
.await
.unwrap();
assert_eq!(count, 8);
}
#[tokio::test(start_paused = true)]
async fn completed_message_stream_leaves_ticks_active() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_millis(20),
..RunnerConfig::default()
});
let mut terminal = terminal(16, 1);
let mut ticks = 0u8;
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut ticks,
|ticks, _| element(Text::raw(format!("ticks={ticks}"))),
async |ticks, signal| match signal {
Signal::Tick => {
*ticks += 1;
if *ticks == 2 {
UpdateResult::Exit
} else {
UpdateResult::Dirty
}
}
_ => UpdateResult::Clean,
},
),
tokio_stream::empty::<Result<Event, Infallible>>(),
tokio_stream::empty::<Result<(), Infallible>>(),
)
.await
.unwrap();
assert_eq!(ticks, 2);
}
#[tokio::test]
async fn message_redraw_and_exit_results_are_respected() {
#[derive(Clone, Copy)]
enum Message {
Clean,
Dirty,
Exit,
}
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(16, 1);
let views = std::cell::Cell::new(0usize);
let mut state = 0u8;
let messages = tokio_stream::iter([
Ok::<_, Infallible>(Message::Clean),
Ok(Message::Dirty),
Ok(Message::Exit),
]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|state, _| {
views.set(views.get() + 1);
element(Text::raw(format!("state={state}")))
},
async |state, signal| match signal {
Signal::Message(Message::Clean) => {
*state = 1;
UpdateResult::Clean
}
Signal::Message(Message::Dirty) => {
*state = 2;
UpdateResult::Dirty
}
Signal::Message(Message::Exit) => {
*state = 3;
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
tokio_stream::empty::<Result<Event, Infallible>>(),
messages,
)
.await
.unwrap();
assert_eq!(views.get(), 2, "initial frame plus the dirty message");
assert!(buffer_text(&terminal).contains("state=2"));
assert_eq!(state, 3, "exit mutates state but does not repaint");
}
#[tokio::test]
async fn message_stream_error_propagates() {
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 messages = tokio_stream::iter([Err::<(), io::Error>(io::Error::other("message boom"))]);
let result = runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|_state, _| element(Text::raw("x")),
async |_state, _signal| UpdateResult::Clean,
),
tokio_stream::empty::<Result<Event, io::Error>>(),
messages,
)
.await;
assert_eq!(
result.expect_err("message error").to_string(),
"message boom"
);
}
struct Notes {
title: String,
hits: Vec<char>,
}
struct NotesView<'app> {
title: &'app str,
hits: &'app [char],
}
impl View for NotesView<'_> {
fn measure(&self, available: Size, _ctx: &RenderCtx) -> Size {
Size::new(available.width, 1.min(available.height))
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
let line = format!("{}:{}", self.title, self.hits.iter().collect::<String>());
surface.set_string(area.x, area.y, &line, ctx.theme.text_style());
}
}
impl AsyncApplication for Notes {
async fn update(&mut self, signal: Signal) -> UpdateResult {
match signal {
Signal::Event(Event::Key(key)) if key.code == KeyCode::Esc => UpdateResult::Exit,
Signal::Event(Event::Key(Key {
code: KeyCode::Char(c),
..
})) => {
self.hits.push(c);
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
}
}
fn view(&self, _frame: u64) -> ScopedElement<'_> {
element(NotesView {
title: &self.title,
hits: &self.hits,
})
}
}
#[tokio::test]
async fn an_application_paints_a_borrowed_view() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(12, 1);
let mut app = Notes {
title: "n".into(),
hits: Vec::new(),
};
let events = tokio_stream::iter([
key(KeyCode::Char('a')),
key(KeyCode::Char('b')),
key(KeyCode::Esc),
]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
&mut app,
events,
no_messages(),
)
.await
.expect("run");
assert_eq!(app.hits, vec!['a', 'b']);
assert!(
buffer_text(&terminal).contains("n:ab"),
"final frame borrows application data: {:?}",
buffer_text(&terminal)
);
}
struct Feed {
items: Vec<u8>,
}
impl AsyncApplication<u8> for Feed {
async fn update(&mut self, signal: Signal<u8>) -> UpdateResult {
match signal {
Signal::Message(0) => UpdateResult::Exit,
Signal::Message(item) => {
self.items.push(item);
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
}
}
fn view(&self, _frame: u64) -> ScopedElement<'_> {
element(Text::raw(format!("items={}", self.items.len())))
}
}
#[tokio::test]
async fn an_application_receives_messages_through_the_same_seam() {
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(12, 1);
let mut app = Feed { items: Vec::new() };
let messages = tokio_stream::iter([Ok::<u8, Infallible>(7), Ok(9), Ok(0)]);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
&mut app,
tokio_stream::empty::<Result<Event, Infallible>>(),
messages,
)
.await
.expect("run");
assert_eq!(app.items, vec![7, 9]);
assert!(buffer_text(&terminal).contains("items=2"));
}
#[tokio::test(start_paused = true)]
async fn redraw_handle_wakes_the_loop_and_repaints() {
use tokio_stream::wrappers::ReceiverStream;
let runner = AsyncRunner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
});
let mut terminal = terminal(12, 1);
let redraw = runner.redraw_handle();
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let views = std::cell::Cell::new(0usize);
let (sender, receiver) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(4);
let producer = {
let counter = std::sync::Arc::clone(&counter);
tokio::spawn(async move {
counter.store(41, std::sync::atomic::Ordering::Release);
redraw.request();
tokio::time::sleep(Duration::from_millis(1)).await;
sender.send(key(KeyCode::Esc)).await.expect("send quit");
})
};
let mut state = std::sync::Arc::clone(&counter);
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
async_from_fn(
&mut state,
|state, _frame| {
views.set(views.get() + 1);
let value = state.load(std::sync::atomic::Ordering::Acquire);
element(Text::raw(format!("value={value}")))
},
async |_state, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
ReceiverStream::new(receiver),
no_messages(),
)
.await
.expect("run");
producer.await.expect("producer");
assert_eq!(views.get(), 2, "initial frame plus the requested repaint");
assert!(
buffer_text(&terminal).contains("value=41"),
"the requested repaint shows the producer's data: {:?}",
buffer_text(&terminal)
);
}
}