use std::result::Result as StdResult;
use std::time::{Duration, Instant};
use color_eyre::eyre::Result;
use futures::stream::StreamExt;
use ratatui::prelude::Backend;
use crate::{application::Application, command::Command};
mod app_input;
mod core;
pub(crate) mod frame_rate;
mod frame_scheduler;
mod pending_work;
use app_input::AppInput;
use frame_rate::{FrameRate, FrameRateError};
use frame_scheduler::FrameScheduler;
use self::core::RuntimeCore;
pub struct Runtime<App: Application> {
core: RuntimeCore<App>,
scheduler: FrameScheduler,
}
impl<App: Application> Runtime<App> {
#[must_use]
pub fn new(flags: App::Flags, frame_rate: FrameRate) -> Self {
let core = RuntimeCore::new(flags);
Self {
core,
scheduler: FrameScheduler::new(frame_rate),
}
}
pub fn try_new(flags: App::Flags, frame_rate: u32) -> StdResult<Self, FrameRateError> {
Ok(Self::new(flags, FrameRate::try_new(frame_rate)?))
}
#[cfg(test)]
fn process_message_batch(&mut self, first_msg: App::Message) {
self.process_input_batch(AppInput::Shared(first_msg));
}
fn process_input_batch(&mut self, first_input: AppInput<App::Message>) {
self.process_app_input(first_input);
let mut processed = 1usize;
let batch_deadline = Instant::now() + Duration::from_micros(100);
while Instant::now() < batch_deadline {
match self.core.app_inputs.try_next_ready() {
Some(input) => {
self.process_app_input(input);
processed += 1;
}
None => break, }
}
tracing::trace!(target: "tears::runtime", messages = processed, "processed message batch");
self.scheduler.mark_subscriptions_dirty();
}
fn process_app_input(&mut self, input: AppInput<App::Message>) {
match input {
AppInput::Shared(msg) => {
let cmd = self.core.app.update(msg);
self.dispatch_update_command(cmd);
}
}
}
fn dispatch_update_command(&mut self, cmd: Command<App::Message>) {
let parts = cmd.into_runtime_parts();
self.scheduler.record_redraw(parts.requests_redraw());
self.core.enqueue_command(parts);
}
fn process_frame_tick<B: Backend>(
&mut self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<(), <B as Backend>::Error> {
tracing::trace!(target: "tears::runtime::frame", "frame tick");
if self.scheduler.take_redraw() {
self.core.render(terminal)?;
tracing::trace!(target: "tears::runtime", "frame rendered");
}
if self.scheduler.take_subscriptions_dirty() {
self.update_subscriptions();
}
Ok(())
}
fn update_subscriptions(&mut self) {
let subscriptions = self.core.app.subscriptions();
let count = subscriptions.len();
self.core.subscription_manager.update(subscriptions);
tracing::debug!(target: "tears::runtime", count, "subscriptions re-evaluated");
}
pub async fn run<B: Backend>(
mut self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<(), <B as Backend>::Error> {
self.core.initialize_subscriptions();
tracing::debug!(target: "tears::runtime", "runtime started");
loop {
tokio::select! {
Some(input) = self.core.app_inputs.next() => {
self.process_input_batch(input);
}
() = self.scheduler.next_work_frame() => {
self.process_frame_tick(terminal)?;
}
_ = self.core.quit_rx.recv() => {
tracing::debug!(target: "tears::runtime", "quit signal received");
break;
}
}
}
tracing::debug!(target: "tears::runtime", "runtime shutting down");
self.core.shutdown();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::stream::{self, BoxStream};
use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use tokio::time::{Duration, sleep, timeout};
use crate::application::Application;
use crate::command::Command;
use crate::subscription::{Subscription, SubscriptionId, SubscriptionSource};
use crate::test_support::{TestApp, TestMessage, TraceRecorder, wait_until};
fn frame_rate(value: u32) -> FrameRate {
FrameRate::try_new(value).expect("frame rate must be valid")
}
struct RedrawControlApp;
#[derive(Debug, Clone)]
enum RedrawControlMessage {
Redraw,
Skip,
}
impl Application for RedrawControlApp {
type Message = RedrawControlMessage;
type Flags = ();
fn new((): ()) -> (Self, Command<Self::Message>) {
(Self, Command::none())
}
fn update(&mut self, msg: Self::Message) -> Command<Self::Message> {
match msg {
RedrawControlMessage::Redraw => Command::none(),
RedrawControlMessage::Skip => Command::none().without_redraw(),
}
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![]
}
}
#[tokio::test]
async fn test_event_loop_new() {
let runtime = Runtime::<TestApp>::new(0, frame_rate(60));
assert_eq!(runtime.core.app.counter, 0);
}
#[tokio::test]
async fn test_event_loop_new_with_different_frame_rates() {
let _runtime1 = Runtime::<TestApp>::new(0, frame_rate(30));
let _runtime2 = Runtime::<TestApp>::new(0, frame_rate(144));
}
#[tokio::test]
async fn test_runtime_frame_scheduler_period_is_accurate() {
let runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let period = runtime.scheduler.frame_period();
assert!(
period >= Duration::from_micros(16_600) && period <= Duration::from_micros(16_700),
"60 FPS period should be ~16.667ms, got {period:?}",
);
let runtime = Runtime::<TestApp>::new(0, frame_rate(144));
let period = runtime.scheduler.frame_period();
assert!(
period >= Duration::from_micros(6_900) && period <= Duration::from_micros(6_950),
"144 FPS period should be ~6.944ms, got {period:?}",
);
}
#[tokio::test]
async fn test_event_loop_process_message_batch_single_message() {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
runtime.process_message_batch(TestMessage::Increment);
assert_eq!(runtime.core.app.counter, 1);
assert!(runtime.scheduler.pending.needs_redraw);
}
#[tokio::test]
async fn test_process_message_batch_without_redraw_leaves_redraw_unchanged() {
let mut runtime = Runtime::<RedrawControlApp>::new((), frame_rate(60));
runtime.scheduler.pending.needs_redraw = false;
runtime.scheduler.pending.subscriptions_dirty = false;
runtime.process_message_batch(RedrawControlMessage::Skip);
assert!(!runtime.scheduler.pending.needs_redraw);
assert!(
runtime.scheduler.pending.subscriptions_dirty,
"redraw suppression must not suppress subscription re-evaluation"
);
}
#[tokio::test]
async fn test_process_message_batch_mixed_commands_redraws() {
let mut runtime = Runtime::<RedrawControlApp>::new((), frame_rate(60));
runtime.scheduler.pending.needs_redraw = false;
runtime.scheduler.pending.subscriptions_dirty = false;
let _ = runtime.core.msg_tx.send(RedrawControlMessage::Redraw);
runtime.process_message_batch(RedrawControlMessage::Skip);
assert!(runtime.scheduler.pending.needs_redraw);
assert!(runtime.scheduler.pending.subscriptions_dirty);
}
#[tokio::test]
async fn test_process_message_batch_redraw_recovers_after_suppression() {
let mut runtime = Runtime::<RedrawControlApp>::new((), frame_rate(60));
runtime.scheduler.pending.needs_redraw = false;
runtime.process_message_batch(RedrawControlMessage::Skip);
assert!(!runtime.scheduler.pending.needs_redraw);
runtime.scheduler.pending.subscriptions_dirty = false;
runtime.process_message_batch(RedrawControlMessage::Redraw);
assert!(runtime.scheduler.pending.needs_redraw);
assert!(runtime.scheduler.pending.subscriptions_dirty);
}
#[tokio::test]
async fn test_process_message_batch_emits_tracing_event() {
let recorder = TraceRecorder::new().with_target("tears::runtime");
let _guard = recorder.set_default();
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
runtime.process_message_batch(TestMessage::Increment);
assert!(
recorder.event_count() >= 1,
"processing a message batch should emit a tracing event"
);
}
#[tokio::test]
async fn test_event_loop_process_message_batch_with_batching() {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let _ = runtime.core.msg_tx.send(TestMessage::Increment);
let _ = runtime.core.msg_tx.send(TestMessage::Increment);
let _ = runtime.core.msg_tx.send(TestMessage::Increment);
runtime.process_message_batch(TestMessage::Increment);
assert_eq!(runtime.core.app.counter, 4);
}
#[tokio::test]
async fn test_process_input_batch_drains_shared_inputs_in_fifo_order() {
struct BatchOrderApp {
messages: Vec<i32>,
}
impl Application for BatchOrderApp {
type Message = i32;
type Flags = ();
fn new((): ()) -> (Self, Command<Self::Message>) {
(
Self {
messages: Vec::new(),
},
Command::none(),
)
}
fn update(&mut self, msg: Self::Message) -> Command<Self::Message> {
self.messages.push(msg);
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![]
}
}
let mut runtime = Runtime::<BatchOrderApp>::new((), frame_rate(60));
runtime.scheduler.pending.subscriptions_dirty = false;
runtime
.core
.msg_tx
.send(2)
.expect("receiver should be open");
runtime
.core
.msg_tx
.send(3)
.expect("receiver should be open");
runtime.process_input_batch(AppInput::Shared(1));
assert_eq!(runtime.core.app.messages, vec![1, 2, 3]);
assert!(runtime.scheduler.pending.subscriptions_dirty);
}
#[tokio::test]
async fn test_event_loop_process_frame_tick_renders_when_needed() -> Result<()> {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
assert!(runtime.scheduler.pending.needs_redraw);
runtime.process_frame_tick(&mut terminal)?;
assert!(!runtime.scheduler.pending.needs_redraw);
Ok(())
}
#[tokio::test]
async fn test_event_loop_process_frame_tick_skips_render_when_not_needed() -> Result<()> {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
runtime.scheduler.pending.needs_redraw = false;
runtime.process_frame_tick(&mut terminal)?;
assert!(!runtime.scheduler.pending.needs_redraw);
Ok(())
}
#[tokio::test]
async fn test_event_loop_run_quits_on_quit_action() -> Result<()> {
let runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let _ = runtime.core.msg_tx.send(TestMessage::Quit);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
timeout(Duration::from_secs(5), runtime.run(&mut terminal))
.await
.expect("run() should quit before the timeout")?;
Ok(())
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn test_event_loop_run_quits_while_idle() -> Result<()> {
let runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let quit_tx = runtime.core.quit_tx.clone();
tokio::spawn(async move {
sleep(Duration::from_secs(1)).await;
let _ = quit_tx.send(());
});
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
timeout(Duration::from_secs(5), runtime.run(&mut terminal))
.await
.expect("run() should quit while idle before the timeout")?;
Ok(())
}
#[tokio::test]
async fn test_frame_branch_gated_off_when_idle() -> Result<()> {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
runtime.process_frame_tick(&mut terminal)?;
assert!(!runtime.scheduler.pending.has_pending_work());
Ok(())
}
#[tokio::test]
async fn test_frame_branch_reenabled_after_message() -> Result<()> {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
runtime.process_frame_tick(&mut terminal)?;
assert!(!runtime.scheduler.pending.has_pending_work());
runtime.process_message_batch(TestMessage::Increment);
assert!(runtime.scheduler.pending.has_pending_work());
Ok(())
}
#[tokio::test]
async fn test_event_loop_process_frame_tick_updates_subscriptions() -> Result<()> {
struct ParkedSource {
spawns: Arc<AtomicUsize>,
}
impl SubscriptionSource for ParkedSource {
type Output = ();
fn stream(&self) -> BoxStream<'static, ()> {
self.spawns.fetch_add(1, Ordering::SeqCst);
stream::pending().boxed()
}
fn id(&self) -> SubscriptionId {
SubscriptionId::of::<Self>(0)
}
}
struct App {
spawns: Arc<AtomicUsize>,
}
impl Application for App {
type Message = ();
type Flags = Arc<AtomicUsize>;
fn new(spawns: Arc<AtomicUsize>) -> (Self, Command<Self::Message>) {
(Self { spawns }, Command::none())
}
fn update(&mut self, (): ()) -> Command<Self::Message> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![Subscription::new(ParkedSource {
spawns: self.spawns.clone(),
})]
}
}
let spawns = Arc::new(AtomicUsize::new(0));
let mut runtime = Runtime::<App>::new(spawns.clone(), frame_rate(60));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
runtime.scheduler.pending.needs_redraw = false;
assert_eq!(spawns.load(Ordering::SeqCst), 0);
runtime.scheduler.pending.subscriptions_dirty = true;
runtime.process_frame_tick(&mut terminal)?;
assert!(!runtime.scheduler.pending.subscriptions_dirty);
wait_until(
|| spawns.load(Ordering::SeqCst) == 1,
"a dirty frame tick must actually start the requested subscription",
)
.await;
Ok(())
}
struct SubCountingApp {
sub_calls: Arc<AtomicUsize>,
}
impl Application for SubCountingApp {
type Message = ();
type Flags = Arc<AtomicUsize>;
fn new(sub_calls: Arc<AtomicUsize>) -> (Self, Command<Self::Message>) {
(Self { sub_calls }, Command::none())
}
fn update(&mut self, (): ()) -> Command<Self::Message> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
self.sub_calls.fetch_add(1, Ordering::SeqCst);
vec![]
}
}
#[tokio::test]
async fn test_subscriptions_not_reevaluated_on_idle_frames() -> Result<()> {
let counter = Arc::new(AtomicUsize::new(0));
let mut runtime = Runtime::<SubCountingApp>::new(counter.clone(), frame_rate(60));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
runtime.process_frame_tick(&mut terminal)?;
runtime.process_frame_tick(&mut terminal)?;
runtime.process_frame_tick(&mut terminal)?;
assert_eq!(
counter.load(Ordering::SeqCst),
0,
"subscriptions() should not be called on idle frames"
);
Ok(())
}
#[tokio::test]
async fn test_subscriptions_reevaluated_after_message() -> Result<()> {
let counter = Arc::new(AtomicUsize::new(0));
let mut runtime = Runtime::<SubCountingApp>::new(counter.clone(), frame_rate(60));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend)?;
runtime.process_frame_tick(&mut terminal)?;
assert_eq!(counter.load(Ordering::SeqCst), 0);
runtime.process_message_batch(());
runtime.process_frame_tick(&mut terminal)?;
assert_eq!(counter.load(Ordering::SeqCst), 1);
runtime.process_frame_tick(&mut terminal)?;
runtime.process_frame_tick(&mut terminal)?;
assert_eq!(counter.load(Ordering::SeqCst), 1);
Ok(())
}
#[tokio::test]
async fn test_finished_subscription_restarted_with_unchanged_id() -> Result<()> {
#[derive(Clone)]
struct RestartCounters {
spawns: Arc<AtomicUsize>,
completions: Arc<AtomicUsize>,
}
struct OneshotSource {
counters: RestartCounters,
}
impl SubscriptionSource for OneshotSource {
type Output = ();
fn stream(&self) -> BoxStream<'static, ()> {
self.counters.spawns.fetch_add(1, Ordering::SeqCst);
let completions = self.counters.completions.clone();
stream::unfold(false, move |emitted| {
let completions = completions.clone();
async move {
if emitted {
completions.fetch_add(1, Ordering::SeqCst);
None
} else {
Some(((), true))
}
}
})
.boxed()
}
fn id(&self) -> SubscriptionId {
SubscriptionId::of::<Self>(0)
}
}
struct RestartApp {
counters: RestartCounters,
}
impl Application for RestartApp {
type Message = ();
type Flags = RestartCounters;
fn new(counters: RestartCounters) -> (Self, Command<Self::Message>) {
(Self { counters }, Command::none())
}
fn update(&mut self, (): ()) -> Command<Self::Message> {
Command::none()
}
fn view(&self, _frame: &mut Frame<'_>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![Subscription::new(OneshotSource {
counters: self.counters.clone(),
})]
}
}
let counters = RestartCounters {
spawns: Arc::new(AtomicUsize::new(0)),
completions: Arc::new(AtomicUsize::new(0)),
};
let mut runtime = Runtime::<RestartApp>::new(counters.clone(), frame_rate(60));
let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
runtime.core.initialize_subscriptions();
wait_until(
|| counters.spawns.load(Ordering::SeqCst) >= 1,
"initial subscription spawn should start before the timeout",
)
.await;
for round in 2..=3 {
wait_until(
|| counters.completions.load(Ordering::SeqCst) >= round - 1,
"current one-shot subscription should finish before re-evaluation",
)
.await;
runtime.process_message_batch(());
runtime.process_frame_tick(&mut terminal)?;
wait_until(
|| counters.spawns.load(Ordering::SeqCst) >= round,
"finished subscription should restart before the timeout",
)
.await;
}
Ok(())
}
}