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,
) -> std::result::Result<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 crate::application::Application;
use crate::command::Command;
use crate::subscription::Subscription;
use crate::test_support::{TestApp, TestMessage};
use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use tokio::time::{Duration, sleep};
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);
}
#[derive(Clone, Default)]
struct EventCounter {
events: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl tracing::Subscriber for EventCounter {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, _event: &tracing::Event<'_>) {
use std::sync::atomic::Ordering;
self.events.fetch_add(1, Ordering::SeqCst);
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
#[tokio::test]
async fn test_process_message_batch_emits_tracing_event() {
use std::sync::atomic::Ordering;
let counter = EventCounter::default();
let events = counter.events.clone();
tracing::subscriber::with_default(counter, || {
let mut runtime = Runtime::<TestApp>::new(0, frame_rate(60));
runtime.process_message_batch(TestMessage::Increment);
});
assert!(
events.load(Ordering::SeqCst) >= 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);
sleep(Duration::from_millis(10)).await;
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)?;
tokio::time::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)?;
tokio::time::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<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::stream::{self, BoxStream};
use crate::subscription::{SubscriptionId, SubscriptionSource};
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);
for _ in 0..100 {
if spawns.load(Ordering::SeqCst) == 1 {
break;
}
sleep(Duration::from_millis(5)).await;
}
assert_eq!(
spawns.load(Ordering::SeqCst),
1,
"a dirty frame tick must actually start the requested subscription"
);
Ok(())
}
struct SubCountingApp {
sub_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl Application for SubCountingApp {
type Message = ();
type Flags = std::sync::Arc<std::sync::atomic::AtomicUsize>;
fn new(
sub_calls: std::sync::Arc<std::sync::atomic::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, std::sync::atomic::Ordering::SeqCst);
vec![]
}
}
#[tokio::test]
async fn test_subscriptions_not_reevaluated_on_idle_frames() -> Result<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
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<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
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<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::stream::{self, BoxStream};
use crate::subscription::{SubscriptionId, SubscriptionSource};
struct OneshotSource {
spawns: Arc<AtomicUsize>,
}
impl SubscriptionSource for OneshotSource {
type Output = ();
fn stream(&self) -> BoxStream<'static, ()> {
self.spawns.fetch_add(1, Ordering::SeqCst);
stream::once(async {}).boxed()
}
fn id(&self) -> SubscriptionId {
SubscriptionId::of::<Self>(0)
}
}
struct RestartApp {
spawns: Arc<AtomicUsize>,
}
impl Application for RestartApp {
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(OneshotSource {
spawns: self.spawns.clone(),
})]
}
}
async fn wait_for_spawns(spawns: &AtomicUsize, target: usize) {
for _ in 0..100 {
if spawns.load(Ordering::SeqCst) >= target {
return;
}
sleep(Duration::from_millis(5)).await;
}
assert!(
spawns.load(Ordering::SeqCst) >= target,
"expected at least {target} spawns, saw {}",
spawns.load(Ordering::SeqCst)
);
}
let spawns = Arc::new(AtomicUsize::new(0));
let mut runtime = Runtime::<RestartApp>::new(spawns.clone(), frame_rate(60));
let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
runtime.core.initialize_subscriptions();
wait_for_spawns(&spawns, 1).await;
for round in 2..=3 {
sleep(Duration::from_millis(10)).await;
runtime.process_message_batch(());
runtime.process_frame_tick(&mut terminal)?;
wait_for_spawns(&spawns, round).await;
}
Ok(())
}
}