hojicha_runtime/program/
event_processor.rs

1//! Event processing logic extracted from Program for testability
2
3use crossterm::event::{Event as CrosstermEvent, KeyEventKind};
4use hojicha_core::event::{Event, KeyEvent};
5use std::sync::mpsc;
6use std::time::Duration;
7
8/// Processes raw crossterm events into hojicha events
9pub struct EventProcessor;
10
11impl EventProcessor {
12    /// Process a crossterm event into a hojicha event
13    pub fn process_crossterm_event(event: CrosstermEvent) -> Option<Event<()>> {
14        match event {
15            CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => {
16                Some(Event::Key(key.into()))
17            }
18            CrosstermEvent::Mouse(mouse) => Some(Event::Mouse(mouse.into())),
19            CrosstermEvent::Resize(width, height) => Some(Event::Resize { width, height }),
20            CrosstermEvent::Paste(data) => Some(Event::Paste(data)),
21            CrosstermEvent::FocusGained => Some(Event::Focus),
22            CrosstermEvent::FocusLost => Some(Event::Blur),
23            _ => None,
24        }
25    }
26
27    /// Coalesce multiple resize events into one
28    pub fn coalesce_resize_events(
29        initial_width: u16,
30        initial_height: u16,
31        rx: &mpsc::Receiver<CrosstermEvent>,
32    ) -> (u16, u16) {
33        let mut width = initial_width;
34        let mut height = initial_height;
35
36        // Drain any additional resize events
37        while let Ok(CrosstermEvent::Resize(w, h)) = rx.try_recv() {
38            width = w;
39            height = h;
40        }
41
42        (width, height)
43    }
44
45    /// Check if an event is a quit event (Ctrl+Q)
46    pub fn is_quit_event<M>(event: &Event<M>) -> bool {
47        if let Event::Key(KeyEvent {
48            key: hojicha_core::event::Key::Char('q'),
49            modifiers,
50        }) = event
51        {
52            return modifiers.contains(crossterm::event::KeyModifiers::CONTROL);
53        }
54        false
55    }
56
57    /// Check if an event is a suspend event (Ctrl+Z)
58    pub fn is_suspend_event<M>(event: &Event<M>) -> bool {
59        if let Event::Key(KeyEvent {
60            key: hojicha_core::event::Key::Char('z'),
61            modifiers,
62        }) = event
63        {
64            return modifiers.contains(crossterm::event::KeyModifiers::CONTROL);
65        }
66        false
67    }
68
69    /// Prioritize and merge events from multiple channels
70    pub fn prioritize_events<M>(
71        message_rx: &mpsc::Receiver<Event<M>>,
72        crossterm_rx: &mpsc::Receiver<CrosstermEvent>,
73        tick_rate: Duration,
74    ) -> Option<Event<M>>
75    where
76        M: Clone,
77    {
78        // First check for available user messages
79        if let Ok(msg) = message_rx.try_recv() {
80            return Some(msg);
81        }
82
83        // If no user messages, check for crossterm events
84        match crossterm_rx.recv_timeout(tick_rate) {
85            Ok(ct_event) => {
86                // Special handling for resize to coalesce multiple events
87                if let CrosstermEvent::Resize(width, height) = ct_event {
88                    let (final_width, final_height) =
89                        Self::coalesce_resize_events(width, height, crossterm_rx);
90                    return Some(Event::Resize {
91                        width: final_width,
92                        height: final_height,
93                    });
94                }
95
96                // Convert crossterm event to hojicha event
97                Self::process_crossterm_event(ct_event)
98                    .map(|e| unsafe { std::mem::transmute_copy(&e) })
99            }
100            Err(mpsc::RecvTimeoutError::Timeout) => {
101                // Check one more time for messages before generating tick
102                if let Ok(msg) = message_rx.try_recv() {
103                    Some(msg)
104                } else {
105                    Some(Event::Tick)
106                }
107            }
108            _ => None,
109        }
110    }
111
112    /// Prioritize events for headless mode (no crossterm events)
113    pub fn prioritize_events_headless<M>(
114        message_rx: &mpsc::Receiver<Event<M>>,
115        tick_rate: Duration,
116    ) -> Option<Event<M>>
117    where
118        M: Clone,
119    {
120        // Try to receive a message with timeout
121        match message_rx.recv_timeout(tick_rate) {
122            Ok(msg) => Some(msg),
123            Err(mpsc::RecvTimeoutError::Timeout) => Some(Event::Tick),
124            _ => None,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
133
134    #[test]
135    fn test_process_key_event() {
136        let key_event = CrosstermEvent::Key(crossterm::event::KeyEvent {
137            code: KeyCode::Char('a'),
138            modifiers: KeyModifiers::empty(),
139            kind: KeyEventKind::Press,
140            state: crossterm::event::KeyEventState::empty(),
141        });
142
143        let result = EventProcessor::process_crossterm_event(key_event);
144        assert!(result.is_some());
145        if let Some(Event::Key(key)) = result {
146            assert_eq!(key.key, hojicha_core::event::Key::Char('a'));
147        } else {
148            panic!("Expected Key event");
149        }
150    }
151
152    #[test]
153    fn test_process_mouse_event() {
154        let mouse_event = CrosstermEvent::Mouse(crossterm::event::MouseEvent {
155            kind: MouseEventKind::Down(MouseButton::Left),
156            column: 10,
157            row: 20,
158            modifiers: KeyModifiers::empty(),
159        });
160
161        let result = EventProcessor::process_crossterm_event(mouse_event);
162        assert!(result.is_some());
163        if let Some(Event::Mouse(mouse)) = result {
164            assert_eq!(mouse.column, 10);
165            assert_eq!(mouse.row, 20);
166        } else {
167            panic!("Expected Mouse event");
168        }
169    }
170
171    #[test]
172    fn test_process_resize_event() {
173        let resize_event = CrosstermEvent::Resize(80, 24);
174
175        let result = EventProcessor::process_crossterm_event(resize_event);
176        assert!(result.is_some());
177        if let Some(Event::Resize { width, height }) = result {
178            assert_eq!(width, 80);
179            assert_eq!(height, 24);
180        } else {
181            panic!("Expected Resize event");
182        }
183    }
184
185    #[test]
186    fn test_process_paste_event() {
187        let paste_event = CrosstermEvent::Paste("test".to_string());
188
189        let result = EventProcessor::process_crossterm_event(paste_event);
190        assert!(result.is_some());
191        if let Some(Event::Paste(text)) = result {
192            assert_eq!(text, "test");
193        } else {
194            panic!("Expected Paste event");
195        }
196    }
197
198    #[test]
199    fn test_process_focus_events() {
200        let focus_gained = CrosstermEvent::FocusGained;
201        let result = EventProcessor::process_crossterm_event(focus_gained);
202        assert!(matches!(result, Some(Event::Focus)));
203
204        let focus_lost = CrosstermEvent::FocusLost;
205        let result = EventProcessor::process_crossterm_event(focus_lost);
206        assert!(matches!(result, Some(Event::Blur)));
207    }
208
209    #[test]
210    fn test_coalesce_resize_events() {
211        let (tx, rx) = mpsc::channel();
212
213        // Send multiple resize events
214        tx.send(CrosstermEvent::Resize(100, 30)).unwrap();
215        tx.send(CrosstermEvent::Resize(110, 35)).unwrap();
216        tx.send(CrosstermEvent::Resize(120, 40)).unwrap();
217
218        let (width, height) = EventProcessor::coalesce_resize_events(80, 24, &rx);
219
220        // Should get the last resize event
221        assert_eq!(width, 120);
222        assert_eq!(height, 40);
223    }
224
225    #[test]
226    fn test_is_quit_event() {
227        let quit_event: Event<()> = Event::Key(KeyEvent {
228            key: hojicha_core::event::Key::Char('q'),
229            modifiers: KeyModifiers::CONTROL,
230        });
231        assert!(EventProcessor::is_quit_event(&quit_event));
232
233        let non_quit_event: Event<()> = Event::Key(KeyEvent {
234            key: hojicha_core::event::Key::Char('q'),
235            modifiers: KeyModifiers::empty(),
236        });
237        assert!(!EventProcessor::is_quit_event(&non_quit_event));
238    }
239
240    #[test]
241    fn test_is_suspend_event() {
242        let suspend_event: Event<()> = Event::Key(KeyEvent {
243            key: hojicha_core::event::Key::Char('z'),
244            modifiers: KeyModifiers::CONTROL,
245        });
246        assert!(EventProcessor::is_suspend_event(&suspend_event));
247
248        let non_suspend_event: Event<()> = Event::Key(KeyEvent {
249            key: hojicha_core::event::Key::Char('z'),
250            modifiers: KeyModifiers::empty(),
251        });
252        assert!(!EventProcessor::is_suspend_event(&non_suspend_event));
253    }
254}