Skip to main content

spec_ai/spec_ai_tui/event/
event_loop.rs

1//! Async event loop integrated with tokio
2
3use super::Event;
4use crossterm::event::EventStream;
5use futures::StreamExt;
6use std::time::Duration;
7use tokio::sync::mpsc;
8
9/// Async event loop that bridges crossterm events with tokio
10pub struct EventLoop {
11    /// Tick rate for periodic updates
12    tick_rate: Duration,
13    /// Receiver for custom events
14    custom_rx: mpsc::UnboundedReceiver<Event>,
15    /// Sender for custom events (cloneable)
16    custom_tx: mpsc::UnboundedSender<Event>,
17}
18
19impl EventLoop {
20    /// Create a new event loop with the given tick rate
21    pub fn new(tick_rate: Duration) -> Self {
22        let (custom_tx, custom_rx) = mpsc::unbounded_channel();
23        Self {
24            tick_rate,
25            custom_rx,
26            custom_tx,
27        }
28    }
29
30    /// Create with default tick rate (100ms)
31    pub fn default_rate() -> Self {
32        Self::new(Duration::from_millis(100))
33    }
34
35    /// Get a sender for custom events
36    ///
37    /// Use this to send application-specific events into the loop
38    /// from other async tasks.
39    pub fn sender(&self) -> mpsc::UnboundedSender<Event> {
40        self.custom_tx.clone()
41    }
42
43    /// Get the next event
44    ///
45    /// This will return:
46    /// - Keyboard/mouse/resize events from crossterm
47    /// - Custom events sent via the sender
48    /// - Tick events at the configured rate
49    ///
50    /// Returns None if the event stream ends.
51    pub async fn next(&mut self) -> Option<Event> {
52        let tick_delay = tokio::time::sleep(self.tick_rate);
53        tokio::pin!(tick_delay);
54
55        let mut event_stream = EventStream::new();
56
57        tokio::select! {
58            // Crossterm terminal events
59            maybe_event = event_stream.next() => {
60                match maybe_event {
61                    Some(Ok(event)) => Some(event.into()),
62                    Some(Err(_)) => None,
63                    None => None,
64                }
65            }
66            // Custom events from other tasks
67            Some(event) = self.custom_rx.recv() => {
68                Some(event)
69            }
70            // Periodic tick
71            _ = &mut tick_delay => {
72                Some(Event::Tick)
73            }
74        }
75    }
76
77    /// Run the event loop with a handler function
78    ///
79    /// The handler is called for each event. Return `false` to stop the loop.
80    pub async fn run<F>(&mut self, mut handler: F)
81    where
82        F: FnMut(Event) -> bool,
83    {
84        #[allow(clippy::while_let_loop)]
85        loop {
86            if let Some(event) = self.next().await {
87                if !handler(event) {
88                    break;
89                }
90            } else {
91                break;
92            }
93        }
94    }
95}
96
97/// Builder for EventLoop
98#[allow(dead_code)]
99pub struct EventLoopBuilder {
100    tick_rate: Duration,
101}
102
103#[allow(dead_code)]
104impl EventLoopBuilder {
105    /// Create a new builder
106    pub fn new() -> Self {
107        Self {
108            tick_rate: Duration::from_millis(100),
109        }
110    }
111
112    /// Set the tick rate
113    pub fn tick_rate(mut self, rate: Duration) -> Self {
114        self.tick_rate = rate;
115        self
116    }
117
118    /// Build the event loop
119    pub fn build(self) -> EventLoop {
120        EventLoop::new(self.tick_rate)
121    }
122}
123
124impl Default for EventLoopBuilder {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[tokio::test]
135    #[ignore = "requires terminal"]
136    async fn test_custom_event() {
137        let mut event_loop = EventLoop::new(Duration::from_secs(10)); // Long tick so it doesn't interfere
138        let sender = event_loop.sender();
139
140        // Send a custom event
141        sender.send(Event::Tick).unwrap();
142
143        // We should get it back
144        let event = tokio::time::timeout(Duration::from_millis(100), event_loop.next())
145            .await
146            .unwrap();
147
148        assert!(matches!(event, Some(Event::Tick)));
149    }
150}