Skip to main content

remendo/
event.rs

1//! Terminal event types and event-to-message translation.
2//!
3//! Defines the `Event` enum and the `handle_event()` function
4//! that translates events into `Message` values via the
5//! keybinding system.
6
7use crate::app::{App, InputMode};
8use crate::config::keys::{KeyAction, KeyCombo};
9use crate::update::Message;
10use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
11
12/// Events the application can receive.
13///
14/// These are translated from raw crossterm events by the
15/// event handler task running in [`crate::tui::Tui`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Event {
18    /// Application initialization.
19    Init,
20    /// Application quit requested.
21    Quit,
22    /// An error occurred in the event handler.
23    Error,
24    /// Periodic tick for background work.
25    Tick,
26    /// Time to render a new frame.
27    Render,
28    /// A key was pressed.
29    Key(KeyEvent),
30    /// A mouse event occurred.
31    Mouse(MouseEvent),
32    /// The terminal was resized.
33    Resize(u16, u16),
34    /// The terminal gained focus.
35    FocusGained,
36    /// The terminal lost focus.
37    FocusLost,
38    /// Text was pasted from the clipboard.
39    Paste(String),
40}
41
42/// Translate a raw terminal event into a `Message` via the keybinding system.
43///
44/// Returns `None` for events that don't produce messages (e.g., mouse
45/// events, focus changes, or key events that don't map to any action).
46pub fn handle_event(app: &App, event: &Event) -> Option<Message> {
47    match *event {
48        Event::Quit => Some(Message::Quit),
49        Event::Tick => Some(Message::Tick),
50        Event::Render => Some(Message::Render),
51        Event::Resize(w, h) => Some(Message::Resize(w, h)),
52        Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
53            // Modal interception priority: Search > Help > Normal
54            if app.input_mode == InputMode::Search {
55                return handle_search_key(key_event);
56            }
57            let combo = KeyCombo::new(key_event.code, key_event.modifiers);
58            // Modal interception: help overlay swallows all keys except ?/Esc
59            if app.show_help {
60                return match app.config.keybindings.action_for(&combo) {
61                    Some(KeyAction::Help) => Some(Message::ToggleHelp),
62                    Some(KeyAction::CloseThread) => Some(Message::Back),
63                    _ => None,
64                };
65            }
66            app.config
67                .keybindings
68                .action_for(&combo)
69                .and_then(action_to_message)
70        }
71        Event::Init => Some(Message::Init),
72        Event::Error => {
73            tracing::error!("terminal event stream error");
74            None
75        }
76        Event::Key(_)
77        | Event::Mouse(_)
78        | Event::FocusGained
79        | Event::FocusLost
80        | Event::Paste(_) => None,
81    }
82}
83
84/// Handle a key event during search input mode.
85///
86/// Bypasses the keybinding system entirely — keys are interpreted
87/// as text input, cursor movement, or search lifecycle actions.
88fn handle_search_key(key_event: KeyEvent) -> Option<Message> {
89    match key_event.code {
90        KeyCode::Char(c)
91            if !key_event
92                .modifiers
93                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
94        {
95            Some(Message::SearchInput(c))
96        }
97        KeyCode::Backspace => Some(Message::SearchInput('\x08')),
98        KeyCode::Enter => Some(Message::SearchSubmit),
99        KeyCode::Esc => Some(Message::SearchCancel),
100        _ => None,
101    }
102}
103
104/// Map a semantic key action to a `Message`.
105///
106/// Returns `Some` for all currently implemented actions. The `Option`
107/// return type is retained for the `.and_then(action_to_message)` call
108/// pattern and for forward compatibility when new `KeyAction` variants
109/// are added before their `Message` counterparts.
110#[allow(clippy::unnecessary_wraps)]
111fn action_to_message(action: KeyAction) -> Option<Message> {
112    match action {
113        KeyAction::Quit => Some(Message::Quit),
114        KeyAction::Refresh => Some(Message::Refresh),
115        KeyAction::ScrollDown => Some(Message::ScrollDown),
116        KeyAction::ScrollUp => Some(Message::ScrollUp),
117        KeyAction::ScrollHalfPageDown => Some(Message::HalfPageDown),
118        KeyAction::ScrollHalfPageUp => Some(Message::HalfPageUp),
119        KeyAction::OpenThread => Some(Message::Select),
120        KeyAction::NextMailbox => Some(Message::NextMailbox),
121        KeyAction::PrevMailbox => Some(Message::PrevMailbox),
122        KeyAction::FocusSidebar => Some(Message::ToggleFocus),
123        KeyAction::CloseThread => Some(Message::Back),
124        KeyAction::Help => Some(Message::ToggleHelp),
125        KeyAction::NextPage => Some(Message::NextPage),
126        KeyAction::PrevPage => Some(Message::PrevPage),
127        KeyAction::Search => Some(Message::SearchStart),
128        KeyAction::ViewRawLog => Some(Message::ViewRawLog),
129        KeyAction::BookmarkToggle => Some(Message::BookmarkToggle),
130        KeyAction::NextComment => Some(Message::NextComment),
131        KeyAction::PrevComment => Some(Message::PrevComment),
132        KeyAction::ViewBaselineLog => Some(Message::ViewBaselineLog),
133        KeyAction::ToggleListContent => Some(Message::ToggleListContent),
134        KeyAction::BookmarkFilter => Some(Message::ToggleBookmarkFilter),
135        KeyAction::CycleSort => Some(Message::CycleSort),
136        KeyAction::ReverseSort => Some(Message::ReverseSort),
137    }
138}
139
140#[cfg(test)]
141#[allow(clippy::expect_used)]
142mod tests {
143    use super::*;
144    use crate::config::Config;
145    use crossterm::event::{KeyCode, KeyModifiers};
146
147    fn make_key_event(code: KeyCode, modifiers: KeyModifiers) -> Event {
148        Event::Key(KeyEvent::new_with_kind(
149            code,
150            modifiers,
151            KeyEventKind::Press,
152        ))
153    }
154
155    #[test]
156    fn event_key_variant() {
157        let event = make_key_event(KeyCode::Char('q'), KeyModifiers::NONE);
158        assert!(matches!(event, Event::Key(_)));
159    }
160
161    #[test]
162    fn event_resize_variant() {
163        let event = Event::Resize(80, 24);
164        assert!(matches!(event, Event::Resize(80, 24)));
165    }
166
167    #[test]
168    fn event_equality() {
169        assert_eq!(Event::Tick, Event::Tick);
170        assert_ne!(Event::Tick, Event::Render);
171    }
172
173    #[test]
174    fn handle_event_quit_via_keybinding() {
175        let app = App::new(Config::default());
176        let event = make_key_event(KeyCode::Char('q'), KeyModifiers::NONE);
177        let msg = handle_event(&app, &event);
178        assert!(matches!(msg, Some(Message::Quit)));
179    }
180
181    #[test]
182    fn handle_event_tick() {
183        let app = App::new(Config::default());
184        let msg = handle_event(&app, &Event::Tick);
185        assert!(matches!(msg, Some(Message::Tick)));
186    }
187
188    #[test]
189    fn handle_event_resize() {
190        let app = App::new(Config::default());
191        let msg = handle_event(&app, &Event::Resize(120, 40));
192        assert!(matches!(msg, Some(Message::Resize(120, 40))));
193    }
194
195    #[test]
196    fn handle_event_unbound_key_returns_none() {
197        let app = App::new(Config::default());
198        let event = make_key_event(KeyCode::F(12), KeyModifiers::NONE);
199        assert!(handle_event(&app, &event).is_none());
200    }
201
202    #[test]
203    fn handle_event_mouse_returns_none() {
204        let app = App::new(Config::default());
205        let event = Event::Mouse(MouseEvent {
206            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
207            column: 0,
208            row: 0,
209            modifiers: KeyModifiers::NONE,
210        });
211        assert!(handle_event(&app, &event).is_none());
212    }
213
214    #[test]
215    fn handle_event_focus_returns_none() {
216        let app = App::new(Config::default());
217        assert!(handle_event(&app, &Event::FocusGained).is_none());
218        assert!(handle_event(&app, &Event::FocusLost).is_none());
219    }
220
221    #[test]
222    fn handle_event_init_produces_message_init() {
223        let app = App::new(Config::default());
224        let msg = handle_event(&app, &Event::Init);
225        assert!(matches!(msg, Some(Message::Init)));
226    }
227
228    #[test]
229    fn handle_event_refresh_via_keybinding() {
230        let app = App::new(Config::default());
231        // Ctrl-r is the default Refresh keybinding
232        let event = make_key_event(KeyCode::Char('r'), KeyModifiers::CONTROL);
233        let msg = handle_event(&app, &event);
234        assert!(matches!(msg, Some(Message::Refresh)));
235    }
236
237    #[test]
238    fn handle_event_scroll_down_via_j() {
239        let app = App::new(Config::default());
240        let event = make_key_event(KeyCode::Char('j'), KeyModifiers::NONE);
241        let msg = handle_event(&app, &event);
242        assert!(matches!(msg, Some(Message::ScrollDown)));
243    }
244
245    #[test]
246    fn handle_event_scroll_up_via_k() {
247        let app = App::new(Config::default());
248        let event = make_key_event(KeyCode::Char('k'), KeyModifiers::NONE);
249        let msg = handle_event(&app, &event);
250        assert!(matches!(msg, Some(Message::ScrollUp)));
251    }
252
253    #[test]
254    fn handle_event_half_page_down_via_ctrl_d() {
255        let app = App::new(Config::default());
256        let event = make_key_event(KeyCode::Char('d'), KeyModifiers::CONTROL);
257        let msg = handle_event(&app, &event);
258        assert!(matches!(msg, Some(Message::HalfPageDown)));
259    }
260
261    #[test]
262    fn handle_event_half_page_up_via_ctrl_u() {
263        let app = App::new(Config::default());
264        let event = make_key_event(KeyCode::Char('u'), KeyModifiers::CONTROL);
265        let msg = handle_event(&app, &event);
266        assert!(matches!(msg, Some(Message::HalfPageUp)));
267    }
268
269    #[test]
270    fn handle_event_select_via_enter() {
271        let app = App::new(Config::default());
272        let event = make_key_event(KeyCode::Enter, KeyModifiers::NONE);
273        let msg = handle_event(&app, &event);
274        assert!(matches!(msg, Some(Message::Select)));
275    }
276
277    #[test]
278    fn handle_event_next_mailbox_via_tab() {
279        let app = App::new(Config::default());
280        let event = make_key_event(KeyCode::Tab, KeyModifiers::NONE);
281        let msg = handle_event(&app, &event);
282        assert!(matches!(msg, Some(Message::NextMailbox)));
283    }
284
285    #[test]
286    fn handle_event_prev_mailbox_via_shift_tab() {
287        let app = App::new(Config::default());
288        let event = make_key_event(KeyCode::Tab, KeyModifiers::SHIFT);
289        let msg = handle_event(&app, &event);
290        assert!(matches!(msg, Some(Message::PrevMailbox)));
291    }
292
293    #[test]
294    fn handle_event_toggle_focus_via_ctrl_s() {
295        let app = App::new(Config::default());
296        let event = make_key_event(KeyCode::Char('s'), KeyModifiers::CONTROL);
297        let msg = handle_event(&app, &event);
298        assert!(matches!(msg, Some(Message::ToggleFocus)));
299    }
300
301    #[test]
302    fn handle_event_init_via_init_event() {
303        let app = App::new(Config::default());
304        let event = Event::Init;
305        let msg = handle_event(&app, &event);
306        assert!(matches!(msg, Some(Message::Init)));
307    }
308
309    #[test]
310    fn handle_search_key_char_input() {
311        let key =
312            KeyEvent::new_with_kind(KeyCode::Char('a'), KeyModifiers::NONE, KeyEventKind::Press);
313        assert!(matches!(
314            handle_search_key(key),
315            Some(Message::SearchInput('a'))
316        ));
317    }
318
319    #[test]
320    fn handle_search_key_backspace() {
321        let key =
322            KeyEvent::new_with_kind(KeyCode::Backspace, KeyModifiers::NONE, KeyEventKind::Press);
323        assert!(matches!(
324            handle_search_key(key),
325            Some(Message::SearchInput('\x08'))
326        ));
327    }
328
329    #[test]
330    fn handle_search_key_enter_submits() {
331        let key = KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Press);
332        assert!(matches!(
333            handle_search_key(key),
334            Some(Message::SearchSubmit)
335        ));
336    }
337
338    #[test]
339    fn handle_search_key_esc_cancels() {
340        let key = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Press);
341        assert!(matches!(
342            handle_search_key(key),
343            Some(Message::SearchCancel)
344        ));
345    }
346
347    #[test]
348    fn handle_search_key_ctrl_char_ignored() {
349        let key = KeyEvent::new_with_kind(
350            KeyCode::Char('a'),
351            KeyModifiers::CONTROL,
352            KeyEventKind::Press,
353        );
354        assert!(handle_search_key(key).is_none());
355    }
356
357    #[test]
358    fn handle_search_key_unknown_returns_none() {
359        let key = KeyEvent::new_with_kind(KeyCode::F(1), KeyModifiers::NONE, KeyEventKind::Press);
360        assert!(handle_search_key(key).is_none());
361    }
362
363    #[test]
364    fn action_to_message_covers_all_variants() {
365        use crate::config::keys::KeyAction;
366        let pairs: Vec<(KeyAction, Message)> = vec![
367            (KeyAction::Quit, Message::Quit),
368            (KeyAction::Refresh, Message::Refresh),
369            (KeyAction::ScrollDown, Message::ScrollDown),
370            (KeyAction::ScrollUp, Message::ScrollUp),
371            (KeyAction::ScrollHalfPageDown, Message::HalfPageDown),
372            (KeyAction::ScrollHalfPageUp, Message::HalfPageUp),
373            (KeyAction::OpenThread, Message::Select),
374            (KeyAction::NextMailbox, Message::NextMailbox),
375            (KeyAction::PrevMailbox, Message::PrevMailbox),
376            (KeyAction::FocusSidebar, Message::ToggleFocus),
377            (KeyAction::CloseThread, Message::Back),
378            (KeyAction::Help, Message::ToggleHelp),
379            (KeyAction::NextPage, Message::NextPage),
380            (KeyAction::PrevPage, Message::PrevPage),
381            (KeyAction::Search, Message::SearchStart),
382            (KeyAction::ViewRawLog, Message::ViewRawLog),
383            (KeyAction::BookmarkToggle, Message::BookmarkToggle),
384            (KeyAction::NextComment, Message::NextComment),
385            (KeyAction::PrevComment, Message::PrevComment),
386            (KeyAction::ViewBaselineLog, Message::ViewBaselineLog),
387            (KeyAction::ToggleListContent, Message::ToggleListContent),
388            (KeyAction::BookmarkFilter, Message::ToggleBookmarkFilter),
389            (KeyAction::CycleSort, Message::CycleSort),
390            (KeyAction::ReverseSort, Message::ReverseSort),
391        ];
392        for (action, expected) in pairs {
393            let result = action_to_message(action);
394            assert!(
395                result.is_some(),
396                "action_to_message({action:?}) returned None"
397            );
398            assert_eq!(
399                std::mem::discriminant(&result.expect("should be Some")),
400                std::mem::discriminant(&expected),
401                "mismatch for {action:?}"
402            );
403        }
404    }
405
406    #[test]
407    fn handle_event_search_mode_routes_to_search_key() {
408        let mut app = App::new(Config::default());
409        app.input_mode = crate::app::InputMode::Search;
410        let event = make_key_event(KeyCode::Char('x'), KeyModifiers::NONE);
411        let msg = handle_event(&app, &event);
412        assert!(matches!(msg, Some(Message::SearchInput('x'))));
413    }
414}