#[cfg(feature = "async")]
mod asynchronous;
#[cfg(feature = "async")]
#[allow(deprecated)]
pub use asynchronous::AsyncSignal;
#[cfg(feature = "async")]
pub use asynchronous::{
AsyncApplication, AsyncFrameSource, AsyncFromFn, AsyncRunner, async_from_fn, no_messages,
};
use std::convert::Infallible;
use std::io::{self, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::event;
use ratatui_core::backend::Backend;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::terminal::{Terminal, TerminalOptions};
use ratatui_crossterm::CrosstermBackend;
use crate::live::RedrawHandle;
use crate::mouse::{SelectionState, paint_selection, selected_text};
use crate::screen::{ScreenMode, Scrollback, close_footer, pin_footer};
use crate::term::clipboard;
use crate::term::image::{ImageLayer, ImageSupport};
use crate::{
Clock, Element, Event, RenderCtx, ScopedElement, SystemClock, TerminalSession, Theme, View,
paint_with_context, translate_event,
};
pub(crate) type PaintRoot<'a> = &'a mut dyn FnMut(&dyn View);
const RESIZE_FRAME_INTERVAL: Duration = Duration::from_millis(16);
fn resize_redraw_at(last_frame: Instant, now: Instant) -> Instant {
last_frame
.checked_add(RESIZE_FRAME_INTERVAL)
.map_or(now, |deadline| deadline.max(now))
}
fn schedule_redraw(deadline: &mut Option<Instant>, at: Instant) {
*deadline = Some(deadline.map_or(at, |current| current.min(at)));
}
struct FrameGraphics {
support: ImageSupport,
layer: ImageLayer,
}
impl FrameGraphics {
fn detected() -> Self {
Self {
support: ImageSupport::detect(),
layer: ImageLayer::new(),
}
}
fn render_context<'a>(&'a self, theme: &'a Theme) -> RenderCtx<'a> {
RenderCtx::new(theme).with_image_graphics(self.support, &self.layer)
}
fn finish_frame(&self) -> io::Result<()> {
let mut output = io::stdout();
self.finish_frame_to(&mut output)
}
fn finish_frame_to(&self, output: &mut impl Write) -> io::Result<()> {
self.layer.emit(output)?;
output.flush()?;
self.layer.clear();
Ok(())
}
}
struct FrameGraphicsCleanup<'a>(&'a FrameGraphics);
impl Drop for FrameGraphicsCleanup<'_> {
fn drop(&mut self) {
let _ = self.0.finish_frame();
}
}
#[derive(Clone, Copy, Debug)]
pub struct RunnerConfig {
pub tick_rate: Duration,
pub screen_mode: ScreenMode,
}
impl Default for RunnerConfig {
fn default() -> Self {
Self {
tick_rate: Duration::from_millis(100),
screen_mode: ScreenMode::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Signal<M = Infallible> {
Tick,
Event(Event),
Message(M),
}
impl<M> Signal<M> {
fn requires_redraw(&self) -> bool {
matches!(self, Self::Event(Event::Resize { .. }))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UpdateResult {
#[default]
Clean,
Consumed,
Dirty,
Exit,
}
pub trait Application<M = Infallible> {
fn update(&mut self, signal: Signal<M>) -> UpdateResult;
fn view(&self, frame: u64) -> ScopedElement<'_>;
}
pub trait FrameSource<M = Infallible> {
fn update(&mut self, signal: Signal<M>) -> UpdateResult;
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>);
}
impl<M, A: Application<M>> FrameSource<M> for &mut A {
fn update(&mut self, signal: Signal<M>) -> UpdateResult {
Application::update(*self, signal)
}
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>) {
paint(Application::view(*self, frame).as_ref());
}
}
pub struct FromFn<'state, S, V, U> {
state: &'state mut S,
view: V,
update: U,
}
pub fn from_fn<S, V, U, M>(state: &mut S, view: V, update: U) -> FromFn<'_, S, V, U>
where
V: FnMut(&S, u64) -> Element,
U: FnMut(&mut S, Signal<M>) -> UpdateResult,
{
FromFn {
state,
view,
update,
}
}
impl<M, S, V, U> FrameSource<M> for FromFn<'_, S, V, U>
where
V: FnMut(&S, u64) -> Element,
U: FnMut(&mut S, Signal<M>) -> UpdateResult,
{
fn update(&mut self, signal: Signal<M>) -> UpdateResult {
(self.update)(self.state, signal)
}
fn frame(&mut self, frame: u64, paint: PaintRoot<'_>) {
paint((self.view)(self.state, frame).as_ref());
}
}
pub trait EventSource<Er = io::Error> {
fn poll_event(&mut self, timeout: Duration) -> Result<Option<Event>, Er>;
}
struct CrosstermEvents;
impl EventSource<io::Error> for CrosstermEvents {
fn poll_event(&mut self, timeout: Duration) -> io::Result<Option<Event>> {
if event::poll(timeout)? {
return Ok(translate_event(event::read()?));
}
Ok(None)
}
}
pub struct ScriptedEvents<I> {
events: I,
idle: fn(Duration),
}
pub fn scripted_events<I>(events: I) -> ScriptedEvents<I::IntoIter>
where
I: IntoIterator<Item = Event>,
{
ScriptedEvents {
events: events.into_iter(),
idle: std::thread::sleep,
}
}
impl<I: Iterator<Item = Event>, Er> EventSource<Er> for ScriptedEvents<I> {
fn poll_event(&mut self, timeout: Duration) -> Result<Option<Event>, Er> {
match self.events.next() {
Some(event) => Ok(Some(event)),
None => {
(self.idle)(timeout);
Ok(None)
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunnerAction {
Wait,
Render(u64),
Exit,
}
#[derive(Clone, Debug)]
pub struct RunnerCore {
next_frame: u64,
dirty: bool,
exited: bool,
}
impl Default for RunnerCore {
fn default() -> Self {
Self::new()
}
}
impl RunnerCore {
pub const fn new() -> Self {
Self {
next_frame: 0,
dirty: true,
exited: false,
}
}
pub fn apply(&mut self, result: UpdateResult) {
match result {
UpdateResult::Clean | UpdateResult::Consumed => {}
UpdateResult::Dirty => self.dirty = true,
UpdateResult::Exit => self.exited = true,
}
}
pub fn request_redraw(&mut self) {
self.dirty = true;
}
pub const fn is_exited(&self) -> bool {
self.exited
}
pub fn next_action(&mut self) -> RunnerAction {
if self.exited {
return RunnerAction::Exit;
}
if self.dirty {
self.dirty = false;
let frame = self.next_frame;
self.next_frame = self.next_frame.wrapping_add(1);
RunnerAction::Render(frame)
} else {
RunnerAction::Wait
}
}
}
pub struct Runner {
config: RunnerConfig,
clock: Arc<dyn Clock + Send + Sync>,
redraw: RedrawHandle,
scrollback: Scrollback,
session_config: Option<crate::host::TerminalSessionConfig>,
text_selection: bool,
}
impl Runner {
pub fn new(config: RunnerConfig) -> Self {
Self::with_clock(config, SystemClock)
}
pub fn with_clock(mut config: RunnerConfig, clock: impl Clock + Send + Sync + 'static) -> Self {
config.tick_rate = config.tick_rate.max(Duration::from_millis(1));
Self {
config,
clock: Arc::new(clock),
redraw: RedrawHandle::default(),
scrollback: Scrollback::new(),
session_config: None,
text_selection: true,
}
}
pub fn scrollback(&self) -> Scrollback {
self.scrollback.clone()
}
pub fn redraw_handle(&self) -> RedrawHandle {
self.redraw.clone()
}
pub fn with_session_config(mut self, config: crate::host::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::host::TerminalSessionConfig::captures_mouse,
)
}
pub fn run<F: FrameSource>(&self, theme: &Theme, mut frames: F) -> io::Result<()> {
let graphics = FrameGraphics::detected();
self.run_inner(
theme,
CrosstermBackend::new(io::stdout()),
&mut frames,
Some(&graphics),
)
}
pub fn run_with_backend<F, B>(&self, theme: &Theme, backend: B, mut frames: F) -> io::Result<()>
where
F: FrameSource,
B: Backend<Error = io::Error>,
{
self.run_inner(theme, backend, &mut frames, None)
}
#[deprecated(since = "0.9.0", note = "use `run(theme, &mut app)` instead")]
pub fn run_app<A: Application>(&self, theme: &Theme, app: &mut A) -> io::Result<()> {
self.run(theme, app)
}
#[deprecated(
since = "0.9.0",
note = "use `run_with_backend(theme, backend, &mut app)` instead"
)]
pub fn run_app_with_backend<A, B>(
&self,
theme: &Theme,
backend: B,
app: &mut A,
) -> io::Result<()>
where
A: Application,
B: Backend<Error = io::Error>,
{
self.run_with_backend(theme, backend, app)
}
pub fn run_driven_by<F, B, E, Er>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
mut frames: F,
mut events: E,
) -> Result<(), Er>
where
F: FrameSource,
B: Backend<Error = Er>,
E: EventSource<Er>,
{
self.drive(terminal, theme, &mut frames, &mut events, None, |_| Ok(()))
}
fn run_inner<F, B>(
&self,
theme: &Theme,
backend: B,
frames: &mut F,
graphics: Option<&FrameGraphics>,
) -> io::Result<()>
where
F: FrameSource,
B: Backend<Error = io::Error>,
{
let mode = self.config.screen_mode;
let split = !mode.is_alternate();
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 _graphics_cleanup = graphics.map(FrameGraphicsCleanup);
let result = self.drive(
&mut terminal,
theme,
frames,
&mut CrosstermEvents,
graphics,
|copied| {
if let Some(graphics) = graphics {
graphics.finish_frame()?;
}
if let Some(text) = copied {
let _ = clipboard::write(&mut io::stdout(), &text)?;
}
Ok(())
},
);
if split {
let _ = close_footer(&mut terminal);
} else {
let _ = terminal.clear();
}
result
}
fn drive<F, B, E, Er, Finish>(
&self,
terminal: &mut Terminal<B>,
theme: &Theme,
frames: &mut F,
events: &mut E,
graphics: Option<&FrameGraphics>,
mut finish_frame: Finish,
) -> Result<(), Er>
where
F: FrameSource,
B: Backend<Error = Er>,
E: EventSource<Er>,
Finish: FnMut(Option<String>) -> Result<(), Er>,
{
let split = !self.config.screen_mode.is_alternate();
let mut core = RunnerCore::new();
let mut selection = RunnerSelection::new(self.selects_text());
let mut last_tick = self.clock.now();
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)?;
}
let mut last_frame = self.clock.now();
let mut redraw_at = None;
'running: loop {
let now = self.clock.now();
if self.redraw.take() {
core.request_redraw();
schedule_redraw(&mut redraw_at, now);
}
if split {
if self.scrollback.flush(terminal, theme)? {
core.request_redraw();
schedule_redraw(&mut redraw_at, now);
}
} else {
self.scrollback.clear();
}
if now.saturating_duration_since(last_tick) >= self.config.tick_rate {
last_tick = now;
let result = frames.update(Signal::Tick);
core.apply(result);
if core.is_exited() {
break;
}
if result == UpdateResult::Dirty {
schedule_redraw(&mut redraw_at, now);
}
}
if redraw_at.is_some_and(|deadline| deadline <= 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)?;
last_frame = self.clock.now();
}
redraw_at = None;
}
let elapsed = self.clock.now().saturating_duration_since(last_tick);
let tick_timeout = self.config.tick_rate.saturating_sub(elapsed);
let redraw_timeout = redraw_at.map_or(tick_timeout, |deadline| {
deadline.saturating_duration_since(self.clock.now())
});
let timeout = tick_timeout.min(redraw_timeout);
if let Some(event) = events.poll_event(timeout)? {
let signal = Signal::Event(event);
let requires_redraw = signal.requires_redraw();
let selection_event = match &signal {
Signal::Event(event) => Some(event.clone()),
_ => None,
};
let result = frames.update(signal);
core.apply(result);
if core.is_exited() {
break 'running;
}
let selection_changed = selection_event.is_some_and(|event| {
selection.handle_event(&event, result, self.clock.as_ref())
});
if requires_redraw || result == UpdateResult::Dirty || selection_changed {
core.request_redraw();
let now = self.clock.now();
let deadline = if requires_redraw {
resize_redraw_at(last_frame, now)
} else {
now
};
schedule_redraw(&mut redraw_at, deadline);
}
}
}
Ok(())
}
}
fn draw<F, B, Er>(
terminal: &mut Terminal<B>,
theme: &Theme,
frames: &mut F,
frame: u64,
graphics: Option<&FrameGraphics>,
selection: &mut RunnerSelection,
) -> Result<Option<String>, Er>
where
B: Backend<Error = Er>,
F: FrameSource,
{
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)
}
struct RunnerSelection {
enabled: bool,
state: SelectionState,
pending_copy: bool,
}
impl RunnerSelection {
fn new(enabled: bool) -> Self {
Self {
enabled,
state: SelectionState::new(),
pending_copy: false,
}
}
fn handle_event(&mut self, event: &Event, result: UpdateResult, clock: &dyn Clock) -> bool {
if !self.enabled {
return false;
}
let Event::Mouse(mouse) = event else {
if matches!(event, Event::Resize { .. }) {
return self.clear();
}
return false;
};
if !mouse.plain() {
return false;
}
if matches!(
mouse.kind,
crate::MouseKind::ScrollUp
| crate::MouseKind::ScrollDown
| crate::MouseKind::ScrollLeft
| crate::MouseKind::ScrollRight
) && result == UpdateResult::Dirty
{
return self.clear();
}
let left_gesture = matches!(
mouse.kind,
crate::MouseKind::Down(crate::MouseButton::Left)
| crate::MouseKind::Drag(crate::MouseButton::Left)
| crate::MouseKind::Up(crate::MouseButton::Left)
);
if !left_gesture {
return false;
}
if result != UpdateResult::Clean {
return self.clear();
}
let changed = self.state.handle_with_clock(mouse, clock);
if changed
&& matches!(mouse.kind, crate::MouseKind::Up(crate::MouseButton::Left))
&& self.state.range().is_some()
{
self.pending_copy = true;
}
changed
}
fn finish_frame(&mut self, buffer: &mut Buffer, area: Rect, theme: &Theme) -> Option<String> {
if !self.enabled {
return None;
}
if self.state.resolve(buffer, area) {
self.pending_copy = true;
}
let Some(range) = self.state.range() else {
self.pending_copy = false;
return None;
};
let copied = self
.pending_copy
.then(|| selected_text(buffer, area, range));
self.pending_copy = false;
paint_selection(buffer, area, range, theme.selection_style());
copied
}
fn clear(&mut self) -> bool {
let changed = self.state.is_active();
self.state.clear();
self.pending_copy = false;
changed
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use super::*;
#[derive(Clone, Copy)]
struct FixedClock(Instant);
impl Clock for FixedClock {
fn now(&self) -> Instant {
self.0
}
}
#[test]
fn zero_tick_rate_is_clamped() {
let runner = Runner::new(RunnerConfig {
tick_rate: Duration::ZERO,
..RunnerConfig::default()
});
assert_eq!(runner.config.tick_rate, Duration::from_millis(1));
}
#[test]
fn explicit_clock_drives_the_runner() {
let now = Instant::now();
let runner = Runner::with_clock(RunnerConfig::default(), FixedClock(now));
assert_eq!(runner.clock.now(), now);
}
#[test]
fn resize_redraws_are_limited_to_one_frame_interval() {
let last_frame = Instant::now();
let during_frame = last_frame + Duration::from_millis(4);
let after_frame = last_frame + Duration::from_millis(20);
assert_eq!(
resize_redraw_at(last_frame, during_frame),
last_frame + RESIZE_FRAME_INTERVAL
);
assert_eq!(resize_redraw_at(last_frame, after_frame), after_frame);
}
#[test]
fn finishing_a_graphics_frame_emits_and_clears_placements() {
let graphics = FrameGraphics {
support: ImageSupport::Kitty,
layer: ImageLayer::new(),
};
let data = crate::term::image::ImageData::from_rgba(1, 1, vec![1, 2, 3, 255]).unwrap();
graphics.layer.record(
ratatui_core::layout::Rect::new(2, 3, 4, 5),
data,
graphics.support,
);
let mut output = Vec::new();
graphics.finish_frame_to(&mut output).unwrap();
assert!(!output.is_empty());
assert!(graphics.layer.is_empty());
}
#[test]
fn runner_core_is_deterministic_and_runtime_free() {
let mut core = RunnerCore::new();
assert_eq!(core.next_action(), RunnerAction::Render(0));
assert_eq!(core.next_action(), RunnerAction::Wait);
core.apply(UpdateResult::Dirty);
assert_eq!(core.next_action(), RunnerAction::Render(1));
core.apply(UpdateResult::Consumed);
assert_eq!(core.next_action(), RunnerAction::Wait);
core.apply(UpdateResult::Exit);
assert_eq!(core.next_action(), RunnerAction::Exit);
}
#[test]
fn default_selection_highlights_and_returns_dragged_text() {
let clock = FixedClock(Instant::now());
let mut selection = RunnerSelection::new(true);
let mut buffer =
crate::testing::render(&Text::raw("hello world"), 11, 1, &Theme::default());
let area = buffer.area;
let down = crate::Mouse::at(crate::MouseKind::Down(crate::MouseButton::Left), 0, 0);
let drag = crate::Mouse::at(crate::MouseKind::Drag(crate::MouseButton::Left), 4, 0);
let up = crate::Mouse::at(crate::MouseKind::Up(crate::MouseButton::Left), 4, 0);
assert!(!selection.handle_event(&Event::Mouse(down), UpdateResult::Clean, &clock));
assert!(selection.handle_event(&Event::Mouse(drag), UpdateResult::Clean, &clock));
assert!(selection.handle_event(&Event::Mouse(up), UpdateResult::Clean, &clock));
let copied = selection.finish_frame(&mut buffer, area, &Theme::default());
assert_eq!(copied.as_deref(), Some("hello"));
for column in 0..=4 {
assert_eq!(buffer[(column, 0)].bg, Theme::default().selection_bg);
}
}
#[test]
fn consumed_mouse_gesture_is_not_selected() {
let clock = FixedClock(Instant::now());
let mut selection = RunnerSelection::new(true);
let down = crate::Mouse::at(crate::MouseKind::Down(crate::MouseButton::Left), 0, 0);
let drag = crate::Mouse::at(crate::MouseKind::Drag(crate::MouseButton::Left), 4, 0);
assert!(!selection.handle_event(&Event::Mouse(down), UpdateResult::Consumed, &clock,));
assert!(!selection.handle_event(&Event::Mouse(drag), UpdateResult::Consumed, &clock,));
assert!(selection.state.range().is_none());
}
#[test]
fn the_default_config_owns_the_alternate_screen() {
assert_eq!(RunnerConfig::default().screen_mode, ScreenMode::Alternate);
}
#[test]
fn text_selection_follows_capture_and_can_be_disabled() {
assert!(Runner::new(RunnerConfig::default()).selects_text());
assert!(
!Runner::new(RunnerConfig {
screen_mode: ScreenMode::split_footer(3),
..RunnerConfig::default()
})
.selects_text()
);
assert!(
!Runner::new(RunnerConfig::default())
.with_text_selection(false)
.selects_text()
);
assert!(
!Runner::new(RunnerConfig::default())
.with_session_config(crate::TerminalSessionConfig {
mouse_capture: crate::MouseCapture::Disabled,
..crate::TerminalSessionConfig::default()
})
.selects_text()
);
}
#[test]
fn the_scrollback_handle_shares_one_queue() {
let runner = Runner::new(RunnerConfig {
screen_mode: ScreenMode::split_footer(4),
..RunnerConfig::default()
});
let handle = runner.scrollback();
assert!(handle.is_empty());
handle.write(|_width| crate::element(Text::raw("queued")));
assert!(
!runner.scrollback().is_empty(),
"every handle sees the same queue"
);
}
use ratatui_core::backend::TestBackend;
use crate::components::Text;
use crate::event::KeyCode;
use crate::view::element;
fn key_event(code: KeyCode) -> Event {
Event::Key(crate::event::Key::new(code))
}
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(ratatui_core::buffer::Cell::symbol)
.collect()
}
fn quiet_runner() -> Runner {
Runner::new(RunnerConfig {
tick_rate: Duration::from_secs(3600),
..RunnerConfig::default()
})
}
#[test]
fn the_initial_frame_paints_before_any_event() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let mut state = ();
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut state,
|(), _frame| element(Text::raw("ready")),
|(), signal| match signal {
Signal::Event(Event::Key(_)) => UpdateResult::Exit,
_ => UpdateResult::Clean,
},
),
scripted_events([key_event(KeyCode::Esc)]),
)
.expect("run");
assert!(
buffer_text(&terminal).contains("ready"),
"a frame is painted before the first signal: {:?}",
buffer_text(&terminal)
);
}
#[test]
fn events_drive_state_and_exit_ends_the_run() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let mut count = 0u32;
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut count,
|count, _frame| element(Text::raw(format!("n={count}"))),
|count, signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
Signal::Event(Event::Key(_)) => {
*count += 1;
UpdateResult::Dirty
}
_ => UpdateResult::Clean,
},
),
scripted_events([
key_event(KeyCode::Char('a')),
key_event(KeyCode::Char('a')),
key_event(KeyCode::Esc),
]),
)
.expect("run");
assert_eq!(count, 2, "both keys counted, Esc exited");
assert!(buffer_text(&terminal).contains("n=2"));
}
#[test]
fn clean_updates_neither_rebuild_nor_repaint() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let views = std::cell::Cell::new(0usize);
let mut state = ();
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut state,
|(), _frame| {
views.set(views.get() + 1);
element(Text::raw("idle"))
},
|(), signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
scripted_events([key_event(KeyCode::Char('a')), key_event(KeyCode::Esc)]),
)
.expect("run");
assert_eq!(views.get(), 1, "only the initial frame was built");
}
struct Notes {
title: String,
hits: Vec<char>,
}
struct NotesView<'app> {
title: &'app str,
hits: &'app [char],
}
impl View for NotesView<'_> {
fn measure(&self, available: crate::Size, _ctx: &RenderCtx) -> crate::Size {
crate::Size::new(available.width, 1.min(available.height))
}
fn render(&self, area: Rect, surface: &mut crate::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 Application for Notes {
fn update(&mut self, signal: Signal) -> UpdateResult {
match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => UpdateResult::Exit,
Signal::Event(Event::Key(crate::event::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,
})
}
}
#[test]
fn an_application_paints_a_borrowed_view() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let mut app = Notes {
title: "n".into(),
hits: Vec::new(),
};
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
&mut app,
scripted_events([
key_event(KeyCode::Char('a')),
key_event(KeyCode::Char('b')),
key_event(KeyCode::Esc),
]),
)
.expect("run");
assert_eq!(app.hits, vec!['a', 'b']);
assert!(
buffer_text(&terminal).contains("n:ab"),
"the frame borrows application data: {:?}",
buffer_text(&terminal)
);
}
#[test]
fn a_redraw_request_repaints_without_an_event() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let redraw = runner.redraw_handle();
let value = std::cell::Cell::new(0u32);
let views = std::cell::Cell::new(0usize);
let mut state = ();
value.set(41);
redraw.request();
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut state,
|(), _frame| {
views.set(views.get() + 1);
element(Text::raw(format!("v={}", value.get())))
},
|(), signal| match signal {
Signal::Event(Event::Key(k)) if k.code == KeyCode::Esc => {
UpdateResult::Exit
}
_ => UpdateResult::Clean,
},
),
scripted_events([key_event(KeyCode::Esc)]),
)
.expect("run");
assert_eq!(views.get(), 2, "initial frame plus the requested repaint");
assert!(buffer_text(&terminal).contains("v=41"));
}
struct QuietBetween<I> {
events: I,
idle_next: bool,
}
impl<I: Iterator<Item = Event>, Er> EventSource<Er> for QuietBetween<I> {
fn poll_event(&mut self, timeout: Duration) -> Result<Option<Event>, Er> {
if self.idle_next {
self.idle_next = false;
std::thread::sleep(timeout);
return Ok(None);
}
match self.events.next() {
Some(event) => {
self.idle_next = true;
Ok(Some(event))
}
None => {
std::thread::sleep(timeout);
Ok(None)
}
}
}
}
#[test]
fn a_resize_repaints_even_when_the_update_stays_clean() {
let runner = quiet_runner();
let mut terminal = terminal(12, 1);
let views = std::cell::Cell::new(0usize);
let mut state = ();
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut state,
|(), _frame| {
views.set(views.get() + 1);
element(Text::raw("sized"))
},
|(), signal| match signal {
Signal::Event(Event::Key(_)) => UpdateResult::Exit,
_ => UpdateResult::Clean,
},
),
QuietBetween {
events: [
Event::Resize {
width: 12,
height: 1,
},
key_event(KeyCode::Esc),
]
.into_iter(),
idle_next: false,
},
)
.expect("run");
assert_eq!(
views.get(),
2,
"the initial frame plus one forced by the resize, despite a clean update"
);
}
#[test]
fn a_split_footer_publishes_above_the_pinned_footer() {
use ratatui_core::layout::Position;
let runner = Runner::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 mut state = ();
runner
.run_driven_by(
&mut terminal,
&Theme::default(),
from_fn(
&mut state,
|(), _frame| {
element(Text::new(vec![
ratatui_core::text::Line::from("FOOTER"),
ratatui_core::text::Line::from("FOOTER"),
]))
},
|(), 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,
},
),
scripted_events([key_event(KeyCode::Char('a')), key_event(KeyCode::Char('q'))]),
)
.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:?}"
);
}
}