use std::time::Duration;
use crossterm::event::{self, Event, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::Backend;
use ratatui::layout::Rect;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::MissedTickBehavior;
use tokio_util::sync::CancellationToken;
use super::{layout_root, render};
use crate::app::{
App, EventBridge, KeyRoute, LiveScreen, LoadedReplay, Mode, ReplayScreen, Selection,
};
use crate::error::ChainViewError;
use crate::event::{AppEvent, Command};
use crate::ui::view::ViewState;
use crate::ui::{chain, depth, payoff, replay, surface, theme};
pub const EVENT_CHANNEL_CAPACITY: usize = 256;
const INPUT_POLL_TIMEOUT: Duration = Duration::from_millis(100);
#[must_use]
pub fn event_channel() -> (mpsc::Sender<AppEvent>, mpsc::Receiver<AppEvent>) {
mpsc::channel(EVENT_CHANNEL_CAPACITY)
}
pub fn run_render_loop<B, R>(
terminal: &mut Terminal<B>,
app: &mut App,
bridge: &mut EventBridge,
view: &mut ViewState,
rx_events: &mut mpsc::Receiver<AppEvent>,
mut route: R,
) -> Result<(), ChainViewError>
where
B: Backend,
R: FnMut(Command),
{
if app.dirty {
view.sync(app);
draw_frame(terminal, app, view)?;
}
while let Some(event) = rx_events.blocking_recv() {
let outcome = step(terminal, app, bridge, view, event, &mut route)?;
if outcome.quit {
break;
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StepOutcome {
redrawn: bool,
quit: bool,
}
fn step<B, R>(
terminal: &mut Terminal<B>,
app: &mut App,
bridge: &mut EventBridge,
view: &mut ViewState,
event: AppEvent,
route: &mut R,
) -> Result<StepOutcome, ChainViewError>
where
B: Backend,
R: FnMut(Command),
{
fold_event(app, event);
bridge.pump(app, &mut *route);
let redrawn = if app.dirty {
view.sync(app);
draw_frame(terminal, app, view)?;
true
} else {
false
};
Ok(StepOutcome {
redrawn,
quit: app.should_quit,
})
}
fn draw_frame<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
view: &ViewState,
) -> Result<(), ChainViewError> {
let view_app: &App = app;
let area = terminal
.draw(|frame| render(view_app, view, frame))
.map_err(|e| ChainViewError::Terminal(e.to_string()))?
.area;
app.mark_drawn();
stash_depth_geometry(app, area);
Ok(())
}
fn stash_depth_geometry(app: &mut App, area: Rect) {
let Mode::Live(live) = &mut app.mode else {
return;
};
if live.screen != LiveScreen::Depth || theme::is_too_small(area) {
return;
}
let body = layout_root(area).body;
live.depth_visible_rows = depth::body_visible_rows(body);
}
fn fold_event(app: &mut App, event: AppEvent) {
match event {
AppEvent::Key(key) => dispatch_key(app, key),
AppEvent::Resize(width, height) => app.on_event(AppEvent::Resize(width, height)),
AppEvent::Tick => app.on_event(AppEvent::Tick),
AppEvent::Market(update) => app.on_event(AppEvent::Market(update)),
AppEvent::ReplaySeek(seek) => app.on_event(AppEvent::ReplaySeek(seek)),
AppEvent::ReplayControl(control) => app.on_event(AppEvent::ReplayControl(control)),
AppEvent::BundleLoaded(result) => app.on_event(AppEvent::BundleLoaded(result)),
}
}
fn dispatch_key(app: &mut App, key: KeyEvent) {
match app.dispatch_key_global(key) {
KeyRoute::Consumed => {}
KeyRoute::ToScreen => {
let before = view_sig(app);
let follow = screen_handle_key(app, key);
if view_sig(app) != before {
app.dirty = true;
}
if let Some(follow) = follow {
app.on_event(follow);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ViewSig {
Live(Selection, u64, u64, Option<usize>),
Replay(Option<(u32, u64, u32)>),
}
#[must_use]
fn view_sig(app: &App) -> ViewSig {
match &app.mode {
Mode::Live(live) => ViewSig::Live(
live.selection,
live.payoff_builder.revision(),
live.surface.revision(),
live.depth_scroll,
),
Mode::Replay(replay) => {
ViewSig::Replay(replay.loaded().and_then(LoadedReplay::selection_key))
}
}
}
#[must_use]
fn screen_handle_key(app: &mut App, key: KeyEvent) -> Option<AppEvent> {
match &mut app.mode {
Mode::Live(state) => match state.screen {
LiveScreen::Chain => chain::handle_key(state, key),
LiveScreen::Depth => depth::handle_key(state, key),
LiveScreen::Surface => surface::handle_key(state, key),
LiveScreen::Payoff => payoff::handle_key(state, key),
},
Mode::Replay(state) => match state.screen {
ReplayScreen::Replay => replay::handle_key(state, key),
ReplayScreen::Payoff => payoff::handle_key_replay(state, key),
},
}
}
#[must_use = "register the returned JoinHandle with the Supervisor so it has a shutdown path"]
pub fn spawn_tick_task(
tick_interval: Duration,
tx_events: mpsc::Sender<AppEvent>,
cancel: CancellationToken,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(tick_interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
() = cancel.cancelled() => break,
_ = ticker.tick() => match tx_events.try_send(AppEvent::Tick) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {}
Err(mpsc::error::TrySendError::Closed(_)) => break,
},
}
}
})
}
#[must_use = "register the returned JoinHandle with the Supervisor so it has a shutdown path"]
pub fn spawn_input_reader(
tx_events: mpsc::Sender<AppEvent>,
cancel: CancellationToken,
) -> JoinHandle<()> {
tokio::task::spawn_blocking(move || read_input_loop(&tx_events, &cancel))
}
fn read_input_loop(tx_events: &mpsc::Sender<AppEvent>, cancel: &CancellationToken) {
while !cancel.is_cancelled() {
match event::poll(INPUT_POLL_TIMEOUT) {
Ok(true) => match event::read() {
Ok(raw) => {
if let Some(app_event) = to_app_event(raw) {
if tx_events.blocking_send(app_event).is_err() {
break;
}
}
}
Err(_) => break,
},
Ok(false) => {}
Err(_) => break,
}
}
}
#[must_use]
fn to_app_event(event: Event) -> Option<AppEvent> {
match event {
Event::Key(key) => Some(AppEvent::Key(key)),
Event::Resize(cols, rows) => Some(AppEvent::Resize(cols, rows)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::{
StepOutcome, event_channel, fold_event, run_render_loop, spawn_tick_task, step,
to_app_event,
};
use crate::app::tests_support::{live_app, ready_replay_app, ready_replay_app_with_fills};
use crate::app::{App, BundleLoad, EventBridge, LiveScreen, Mode, ScreenLoad};
use crate::event::{AppEvent, Command};
use crate::ui::view::ViewState;
#[track_caller]
fn test_terminal() -> Terminal<TestBackend> {
match Terminal::new(TestBackend::new(80, 24)) {
Ok(t) => t,
Err(e) => panic!("TestBackend terminal construction failed: {e}"),
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn noop_route() -> impl FnMut(Command) {
|_command: Command| {}
}
#[test]
fn test_step_idle_tick_does_not_redraw() {
let (mut app, _rx) = live_app(LiveScreen::Chain, ScreenLoad::Ready, false);
assert!(!app.dirty, "the app is clean after the initial frame");
let (mut bridge, _senders) = EventBridge::new(64);
let mut view = ViewState::new();
let mut terminal = test_terminal();
let mut route = noop_route();
let outcome = match step(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
AppEvent::Tick,
&mut route,
) {
Ok(o) => o,
Err(e) => panic!("step failed: {e}"),
};
assert_eq!(
outcome,
StepOutcome {
redrawn: false,
quit: false
},
"an idle non-motion tick sets no dirty, so no redraw and no quit"
);
assert!(!app.dirty);
}
#[test]
fn test_step_resize_redraws_and_clears_dirty() {
let (mut app, _rx) = live_app(LiveScreen::Chain, ScreenLoad::Loading, false);
let (mut bridge, _senders) = EventBridge::new(64);
let mut view = ViewState::new();
let mut terminal = test_terminal();
let mut route = noop_route();
let outcome = match step(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
AppEvent::Resize(100, 30),
&mut route,
) {
Ok(o) => o,
Err(e) => panic!("step failed: {e}"),
};
assert!(outcome.redrawn, "a resize sets dirty, so the loop redraws");
assert!(!app.dirty, "dirty is cleared after the draw");
assert!(!outcome.quit);
}
#[test]
fn test_step_stashes_depth_viewport_off_the_pure_draw() {
let (mut app, _rx) = live_app(LiveScreen::Depth, ScreenLoad::Ready, false);
let depth_rows = |app: &App| match &app.mode {
Mode::Live(live) => live.depth_visible_rows,
Mode::Replay(_) => panic!("expected a live app"),
};
assert_eq!(
depth_rows(&app),
0,
"no viewport stashed before the first draw"
);
let (mut bridge, _senders) = EventBridge::new(64);
let mut view = ViewState::new();
let mut terminal = test_terminal(); let mut route = noop_route();
let outcome = match step(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
AppEvent::Resize(80, 24),
&mut route,
) {
Ok(o) => o,
Err(e) => panic!("step failed: {e}"),
};
assert!(outcome.redrawn, "the resize redrew the depth screen");
assert!(
depth_rows(&app) > 0,
"the depth draw stashed a positive viewport height off the draw: {}",
depth_rows(&app),
);
}
#[test]
fn test_step_does_not_stash_depth_viewport_off_the_depth_screen() {
let (mut app, _rx) = live_app(LiveScreen::Chain, ScreenLoad::Ready, false);
let (mut bridge, _senders) = EventBridge::new(64);
let mut view = ViewState::new();
let mut terminal = test_terminal();
let mut route = noop_route();
let _ = step(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
AppEvent::Resize(80, 24),
&mut route,
);
match &app.mode {
Mode::Live(live) => assert_eq!(
live.depth_visible_rows, 0,
"a Chain draw leaves the depth viewport unstashed",
),
Mode::Replay(_) => panic!("expected a live app"),
}
}
#[test]
fn test_step_quit_key_redraws_final_frame_and_signals_stop() {
let (mut app, _rx) = live_app(LiveScreen::Chain, ScreenLoad::Loading, false);
let (mut bridge, _senders) = EventBridge::new(64);
let mut view = ViewState::new();
let mut terminal = test_terminal();
let mut route = noop_route();
let outcome = match step(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
AppEvent::Key(key(KeyCode::Char('q'))),
&mut route,
) {
Ok(o) => o,
Err(e) => panic!("step failed: {e}"),
};
assert!(app.should_quit);
assert!(outcome.quit, "the quit key signals the loop to stop");
assert!(outcome.redrawn, "quit set dirty, so a final frame is drawn");
}
#[test]
fn test_run_render_loop_drains_until_channel_closes() {
let (mut app, _cmd_rx) = live_app(LiveScreen::Chain, ScreenLoad::Loading, false);
let (mut bridge, _senders) = EventBridge::new(64);
let mut terminal = test_terminal();
let (tx, mut rx) = event_channel();
let _ = tx.try_send(AppEvent::Resize(100, 30));
let _ = tx.try_send(AppEvent::Tick);
drop(tx);
let mut view = ViewState::new();
match run_render_loop(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
&mut rx,
noop_route(),
) {
Ok(()) => {}
Err(e) => panic!("render loop failed: {e}"),
}
assert!(!app.should_quit, "no quit event was sent");
assert!(!app.dirty, "the last processed frame cleared dirty");
}
#[test]
fn test_run_render_loop_breaks_on_quit_even_with_open_sender() {
let (mut app, _cmd_rx) = live_app(LiveScreen::Chain, ScreenLoad::Loading, false);
let (mut bridge, _senders) = EventBridge::new(64);
let mut terminal = test_terminal();
let (tx, mut rx) = event_channel();
let _ = tx.try_send(AppEvent::Key(key(KeyCode::Char('q'))));
let _keep_open = tx.clone();
drop(tx);
let mut view = ViewState::new();
match run_render_loop(
&mut terminal,
&mut app,
&mut bridge,
&mut view,
&mut rx,
noop_route(),
) {
Ok(()) => {}
Err(e) => panic!("render loop failed: {e}"),
}
assert!(app.should_quit, "the loop broke on the quit key");
}
#[test]
fn test_fold_event_global_quit_key_is_consumed_no_command() {
let (mut app, mut rx) = live_app(LiveScreen::Chain, ScreenLoad::Loading, false);
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('q'))));
assert!(app.should_quit);
assert!(rx.try_recv().is_err(), "a global quit emits no command");
}
#[test]
fn test_fold_event_unbound_key_forwarded_to_replay_screen_moves_cursor() {
let (mut app, mut rx) = ready_replay_app(6);
fold_event(&mut app, AppEvent::Key(key(KeyCode::Right)));
assert!(
app.dirty,
"the scrub moved the play-head, so the frame redraws"
);
match &app.mode {
Mode::Replay(replay) => match &replay.bundle {
BundleLoad::Ready(loaded) => assert_eq!(loaded.cursor.position(), 1),
other => panic!("expected a Ready bundle, got {other:?}"),
},
Mode::Live(_) => panic!("expected a replay app"),
}
assert!(rx.try_recv().is_err(), "a scrub emits no command (#33)");
}
#[test]
fn test_fold_event_replay_drill_down_selects_fill_and_marks_dirty() {
let (mut app, mut rx) = ready_replay_app_with_fills(6);
assert!(!app.dirty, "the app is clean after its initial frame");
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('.'))));
assert!(app.dirty, "stepping the drill-down requests a redraw");
assert!(rx.try_recv().is_err(), "a drill-down step emits no command");
match &app.mode {
Mode::Replay(replay) => match &replay.bundle {
BundleLoad::Ready(loaded) => {
assert!(loaded.selection.is_some(), "the drill-down selected a fill",)
}
other => panic!("expected a Ready bundle, got {other:?}"),
},
Mode::Live(_) => panic!("expected a replay app"),
}
}
#[test]
fn test_fold_event_replay_drill_down_noop_when_no_fills_makes_no_dirty() {
let (mut app, _rx) = ready_replay_app(6);
assert!(!app.dirty, "the app is clean after its initial frame");
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('.'))));
assert!(
!app.dirty,
"an empty-fills drill-down changes nothing, no redraw"
);
}
#[test]
fn test_fold_event_modal_help_swallows_forwarded_key() {
let (mut app, _rx) = ready_replay_app(6);
app.help_open = true;
fold_event(&mut app, AppEvent::Key(key(KeyCode::Right)));
assert!(app.help_open, "help stays open");
match &app.mode {
Mode::Replay(replay) => match &replay.bundle {
BundleLoad::Ready(loaded) => {
assert_eq!(
loaded.cursor.position(),
0,
"the modal overlay swallows the key"
);
}
other => panic!("expected a Ready bundle, got {other:?}"),
},
Mode::Live(_) => panic!("expected a replay app"),
}
}
#[test]
fn test_fold_event_chain_move_strike_selects_and_marks_dirty() {
let (mut app, mut rx) = live_app(LiveScreen::Chain, ScreenLoad::Ready, false);
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('j'))));
assert!(!app.should_quit);
assert!(app.dirty, "moving the strike cursor requests a redraw");
assert!(rx.try_recv().is_err(), "local nav emits no command");
match &app.mode {
Mode::Live(live) => assert!(
live.selection.focused_row.is_some(),
"the strike cursor is placed",
),
Mode::Replay(_) => panic!("expected a live app"),
}
}
#[test]
fn test_fold_event_payoff_add_leg_marks_dirty() {
let (mut app, mut rx) = live_app(LiveScreen::Payoff, ScreenLoad::Ready, false);
assert!(!app.dirty, "the app is clean after its initial frame");
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('a'))));
assert!(app.dirty, "appending a builder leg requests a redraw");
assert!(rx.try_recv().is_err(), "a builder edit emits no command");
match &app.mode {
Mode::Live(live) => assert_eq!(
live.payoff_builder.legs().len(),
1,
"the focused leg is appended",
),
Mode::Replay(_) => panic!("expected a live app"),
}
}
#[test]
fn test_fold_event_payoff_unbound_key_makes_no_change_no_dirty() {
let (mut app, _rx) = live_app(LiveScreen::Payoff, ScreenLoad::Ready, false);
assert!(!app.dirty, "the app is clean after its initial frame");
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('z'))));
assert!(
!app.dirty,
"an unbound key changes nothing and requests no redraw"
);
}
#[test]
fn test_fold_event_chain_unbound_key_makes_no_change_no_dirty() {
let (mut app, _rx) = live_app(LiveScreen::Chain, ScreenLoad::Ready, false);
assert!(!app.dirty, "the app is clean after its initial frame");
fold_event(&mut app, AppEvent::Key(key(KeyCode::Char('z'))));
assert!(
!app.dirty,
"an unbound key changes nothing and requests no redraw"
);
assert!(!app.should_quit);
}
#[test]
fn test_to_app_event_maps_key_and_resize_ignores_others() {
let mapped_key = to_app_event(Event::Key(key(KeyCode::Char('j'))));
assert!(matches!(mapped_key, Some(AppEvent::Key(_))));
assert!(matches!(
to_app_event(Event::Resize(120, 40)),
Some(AppEvent::Resize(120, 40))
));
assert!(to_app_event(Event::FocusGained).is_none());
assert!(to_app_event(Event::FocusLost).is_none());
assert!(to_app_event(Event::Paste("x".to_owned())).is_none());
}
#[tokio::test(start_paused = true)]
async fn test_spawn_tick_task_emits_ticks_and_stops_on_cancel() {
let (tx, mut rx) = mpsc::channel::<AppEvent>(8);
let cancel = CancellationToken::new();
let handle = spawn_tick_task(Duration::from_millis(250), tx, cancel.clone());
assert!(matches!(rx.recv().await, Some(AppEvent::Tick)));
cancel.cancel();
match handle.await {
Ok(()) => {}
Err(e) => panic!("tick task join failed: {e}"),
}
}
#[tokio::test(start_paused = true)]
async fn test_spawn_tick_task_stops_when_event_channel_closes() {
let (tx, mut rx) = mpsc::channel::<AppEvent>(8);
let cancel = CancellationToken::new();
let handle = spawn_tick_task(Duration::from_millis(250), tx, cancel.clone());
assert!(matches!(rx.recv().await, Some(AppEvent::Tick)));
drop(rx);
tokio::time::advance(Duration::from_millis(300)).await;
match handle.await {
Ok(()) => {}
Err(e) => panic!("tick task join failed: {e}"),
}
let _ = cancel;
}
}