diff options
Diffstat (limited to 'crates/atuin-ai/src/tui/event.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/event.rs | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/event.rs b/crates/atuin-ai/src/tui/event.rs new file mode 100644 index 00000000..8efbf522 --- /dev/null +++ b/crates/atuin-ai/src/tui/event.rs @@ -0,0 +1,303 @@ +use crate::tui::App; +use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; +use eyre::{Result, eyre}; +use futures::StreamExt; +use std::time::Duration; +use tokio::time; + +/// Base tick interval for the event loop (fast for responsive streaming) +const BASE_TICK_INTERVAL: Duration = Duration::from_millis(50); + +/// Application events that drive the TUI state machine. +/// +/// # Event Types +/// - `Key`: Keyboard input (filtered to KeyEventKind::Press only) +/// - `Tick`: Periodic event for updates (50ms base interval) +/// - `Resize`: Terminal window resize +/// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming +/// +/// # Design Decisions +/// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState +/// - Stream events are placeholders - will be wired to channels in Phase 3 +/// - Resize handling enables responsive layout adjustments +#[derive(Debug, Clone)] +pub enum AppEvent { + /// Keyboard input event (filtered to Press events only) + Key(KeyEvent), + + /// Periodic tick for updates (50ms base interval; spinner timing in AppState) + Tick, + + /// Terminal resize event (width, height) + Resize(u16, u16), + + /// Stream chunk received (Phase 3 placeholder) + StreamChunk(String), + + /// Stream completed successfully (Phase 3 placeholder) + StreamDone, + + /// Stream error occurred (Phase 3 placeholder) + StreamError(String), +} + +/// Async event loop that drives the TUI with prioritized event handling. +/// +/// # Priority Model (Biased Select) +/// 1. **Stream data** - Highest priority (future Phase 3 streaming) +/// 2. **Keyboard input** - Medium priority (user responsiveness) +/// 3. **Tick events** - Lowest priority (spinner animation) +/// +/// This ensures stream data is processed immediately when available, +/// keyboard input is responsive, and spinner updates don't block higher priority events. +/// +/// # Graceful Shutdown +/// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop +/// - EventStream close (stdin EOF) triggers shutdown +/// - Shutdown flag can be checked/set externally for controlled termination +/// +/// # Example +/// ```no_run +/// use atuin_ai::tui::EventLoop; +/// +/// # async fn example() -> eyre::Result<()> { +/// let mut event_loop = EventLoop::new(); +/// loop { +/// let event = event_loop.run().await?; +/// // Handle event... +/// # break; +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct EventLoop { + /// Tick interval timer (created lazily on first run) + tick_timer: Option<time::Interval>, + + /// Flag indicating a render was requested (future use in Phase 2) + #[allow(dead_code)] + render_requested: bool, + + /// Shutdown flag - when true, event loop will terminate + shutdown: bool, +} + +impl EventLoop { + /// Create a new EventLoop with default settings. + /// + /// # Defaults + /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState) + /// - Render requested: false + /// - Shutdown: false + pub fn new() -> Self { + Self { + tick_timer: None, + render_requested: false, + shutdown: false, + } + } + + /// Run the event loop, returning the next application event. + /// + /// # Priority Model + /// Uses `tokio::select!` with `biased;` mode to enforce priority: + /// 1. Stream data (placeholder for Phase 3) + /// 2. Keyboard input with rapid keypress batching + /// 3. Tick for spinner animation + /// + /// # Keyboard Handling + /// - Filters to KeyEventKind::Press on all platforms for safety + /// - Batching of rapid keypresses will be implemented in Phase 2 + /// - Currently returns individual key events + /// + /// # Graceful Shutdown + /// - SIGINT (Ctrl+C) triggers shutdown and returns last event + /// - EventStream close (stdin EOF) triggers shutdown + /// - Shutdown flag can be checked after this returns + /// + /// # Errors + /// - Returns error if terminal event stream encounters an error + /// - EventStream close is handled gracefully as shutdown signal + /// + /// # Example + /// ```no_run + /// # use atuin_ai::tui::EventLoop; + /// # async fn example() -> eyre::Result<()> { + /// let mut event_loop = EventLoop::new(); + /// while !event_loop.is_shutdown() { + /// match event_loop.run().await? { + /// // Handle events... + /// # _ => break, + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn run(&mut self) -> Result<AppEvent> { + // Create async event stream for keyboard/terminal events + let mut reader = EventStream::new(); + + // Get or create the tick timer (reused across calls to maintain timing) + // Uses fast base tick for responsive streaming; spinner timing handled in AppState + let tick_timer = self.tick_timer.get_or_insert_with(|| { + let mut interval = time::interval(BASE_TICK_INTERVAL); + // Skip the first immediate tick + interval.reset(); + interval + }); + + loop { + if self.shutdown { + break; + } + + // Biased select: prioritize stream > keyboard > tick + let event = tokio::select! { + biased; + + // Priority 1: Stream data (placeholder for Phase 3) + // In Phase 3, this will be: + // Some(chunk) = stream_rx.recv() => { ... } + + // Priority 2: Keyboard input + maybe_event = reader.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) => { + // Filter to Press events only for cross-platform safety + if key.kind == KeyEventKind::Press { + // Note: Rapid keypress batching will be implemented in Phase 2 + // when we integrate with the state machine. + // For now, just return individual key events. + Some(AppEvent::Key(key)) + } else { + None + } + } + Some(Ok(Event::Resize(w, h))) => { + Some(AppEvent::Resize(w, h)) + } + Some(Err(e)) => { + return Err(eyre!("terminal event error: {}", e)); + } + None => { + // EventStream closed (stdin EOF) - trigger shutdown + self.shutdown = true; + None + } + _ => { + // Ignore other event types (mouse, focus, etc.) + None + } + } + } + + // Priority 3: Tick for spinner animation + _ = tick_timer.tick() => { + Some(AppEvent::Tick) + } + + // SIGINT handling (Ctrl+C) - cross-platform + _ = tokio::signal::ctrl_c() => { + self.shutdown = true; + // Return one more event to allow graceful shutdown handling + Some(AppEvent::Tick) + } + }; + + if let Some(app_event) = event { + return Ok(app_event); + } + } + + // Loop exited due to shutdown - return final tick to allow cleanup + Ok(AppEvent::Tick) + } + + /// Check if the event loop has been signaled to shut down. + /// + /// This can be used to cleanly exit the main TUI loop after receiving + /// a shutdown signal (Ctrl+C, stdin close, etc.) + pub fn is_shutdown(&self) -> bool { + self.shutdown + } + + /// Signal the event loop to shut down. + /// + /// The shutdown will take effect on the next iteration of `run()`. + pub fn shutdown(&mut self) { + self.shutdown = true; + } + + /// Poll for next event and apply to app state. + /// + /// This is a convenience method that combines `run()` with `App` state updates. + /// Returns true if app should continue, false if should exit. + /// + /// # Example + /// ```no_run + /// # use atuin_ai::tui::{EventLoop, App}; + /// # async fn example() -> eyre::Result<()> { + /// let mut event_loop = EventLoop::new(); + /// let mut app = App::new(); + /// + /// while event_loop.poll_and_apply(&mut app).await? { + /// // Render app state... + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn poll_and_apply(&mut self, app: &mut App) -> Result<bool> { + let event = self.run().await?; + + match event { + AppEvent::Key(key) => { + app.handle_key(key); + } + AppEvent::Tick => { + app.state.tick(); + } + AppEvent::Resize(_, _) => { + // Render will be triggered anyway + } + AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => { + // Placeholder for Phase 3 + } + } + + Ok(!app.state.should_exit) + } +} + +impl Default for EventLoop { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_loop_creation() { + let event_loop = EventLoop::new(); + assert!(!event_loop.shutdown); + } + + #[test] + fn test_shutdown_flag() { + let mut event_loop = EventLoop::new(); + assert!(!event_loop.is_shutdown()); + + event_loop.shutdown(); + assert!(event_loop.is_shutdown()); + } + + // Note: Cannot easily test run() in unit tests since it requires a TTY. + // Integration tests should verify: + // 1. Tick events are generated at 150ms intervals + // 2. Keyboard events are properly filtered to Press only + // 3. Rapid keypresses are batched + // 4. SIGINT triggers graceful shutdown + // 5. Resize events are propagated correctly +} |
