aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/event.rs
blob: 8efbf522ece43cd78f5e1cbfa29609829e995501 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
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
}