#[cfg(feature = "async")]
mod asynchronous;
#[cfg(feature = "async")]
pub use asynchronous::AsyncRunner;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use crossterm::event;
use ratatui_core::backend::Backend;
use ratatui_core::terminal::{Terminal, TerminalOptions};
use ratatui_crossterm::CrosstermBackend;
use crate::live::RedrawHandle;
use crate::screen::{ScreenMode, Scrollback, close_footer, pin_footer};
use crate::{
Clock, Element, Event, ScopedElement, SystemClock, TerminalSession, Theme, View, paint,
translate_event,
};
#[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 {
Tick,
Event(Event),
}
impl Signal {
fn requires_redraw(&self) -> bool {
matches!(self, Self::Event(Event::Resize { .. }))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UpdateResult {
#[default]
Clean,
Dirty,
Exit,
}
pub trait Application {
fn update(&mut self, signal: Signal) -> UpdateResult;
fn view(&self, frame: u64) -> ScopedElement<'_>;
}
#[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::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>,
}
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,
}
}
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 run<S, V, U>(&self, theme: &Theme, state: &mut S, view: V, update: U) -> io::Result<()>
where
V: FnMut(&S, u64) -> Element,
U: FnMut(&mut S, Signal) -> UpdateResult,
{
self.run_with_backend(
theme,
CrosstermBackend::new(io::stdout()),
state,
view,
update,
)
}
pub fn run_app<A: Application>(&self, theme: &Theme, app: &mut A) -> io::Result<()> {
self.run_app_with_backend(theme, CrosstermBackend::new(io::stdout()), app)
}
pub fn run_with_backend<S, B, V, U>(
&self,
theme: &Theme,
backend: B,
state: &mut S,
mut view: V,
update: U,
) -> io::Result<()>
where
B: Backend<Error = io::Error>,
V: FnMut(&S, u64) -> Element,
U: FnMut(&mut S, Signal) -> UpdateResult,
{
self.run_with_backend_inner(
theme,
backend,
state,
|state, frame, paint_root| {
let root = view(state, frame);
paint_root(root.as_ref());
},
update,
)
}
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_inner(
theme,
backend,
app,
|app, frame, paint_root| {
let root = app.view(frame);
paint_root(root.as_ref());
},
Application::update,
)
}
fn run_with_backend_inner<S, B, V, U>(
&self,
theme: &Theme,
backend: B,
state: &mut S,
mut view: V,
mut update: U,
) -> io::Result<()>
where
B: Backend<Error = io::Error>,
V: FnMut(&S, u64, &mut dyn FnMut(&dyn View)),
U: FnMut(&mut S, Signal) -> UpdateResult,
{
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 mut core = RunnerCore::new();
let mut last_tick = self.clock.now();
if split {
pin_footer(&mut terminal)?;
}
if let RunnerAction::Render(frame) = core.next_action() {
draw(&mut terminal, theme, &mut view, state, frame)?;
}
'running: loop {
if self.redraw.take() {
core.request_redraw();
}
if split {
if self.scrollback.flush(&mut terminal, theme)? {
core.request_redraw();
}
} else {
self.scrollback.clear();
}
let now = self.clock.now();
if now.saturating_duration_since(last_tick) >= self.config.tick_rate {
last_tick = now;
core.apply(update(state, Signal::Tick));
if core.is_exited() {
break;
}
}
if let RunnerAction::Render(frame) = core.next_action() {
if split {
terminal.autoresize()?;
pin_footer(&mut terminal)?;
}
draw(&mut terminal, theme, &mut view, state, frame)?;
}
let elapsed = self.clock.now().saturating_duration_since(last_tick);
let timeout = self.config.tick_rate.saturating_sub(elapsed);
if event::poll(timeout)?
&& let Some(event) = translate_event(event::read()?)
{
let signal = Signal::Event(event);
let requires_redraw = signal.requires_redraw();
core.apply(update(state, signal));
if core.is_exited() {
break 'running;
}
if requires_redraw {
core.request_redraw();
}
}
}
if split {
let _ = close_footer(&mut terminal);
} else {
let _ = terminal.clear();
}
Ok(())
}
}
fn draw<S, B, V, Er>(
terminal: &mut Terminal<B>,
theme: &Theme,
view: &mut V,
state: &S,
frame: u64,
) -> Result<(), Er>
where
B: Backend<Error = Er>,
V: FnMut(&S, u64, &mut dyn FnMut(&dyn View)),
{
terminal.draw(|terminal_frame| {
let area = terminal_frame.area();
view(state, frame, &mut |root| {
paint(terminal_frame.buffer_mut(), area, theme, root, &[]);
});
})?;
Ok(())
}
#[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 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::Exit);
assert_eq!(core.next_action(), RunnerAction::Exit);
}
#[test]
fn the_default_config_owns_the_alternate_screen() {
assert_eq!(RunnerConfig::default().screen_mode, ScreenMode::Alternate);
}
#[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(crate::components::Text::raw("queued")));
assert!(
!runner.scrollback().is_empty(),
"every handle sees the same queue"
);
}
}