1use crate::app::{App, InputMode};
8use crate::config::keys::{KeyAction, KeyCombo};
9use crate::update::Message;
10use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Event {
18 Init,
20 Quit,
22 Error,
24 Tick,
26 Render,
28 Key(KeyEvent),
30 Mouse(MouseEvent),
32 Resize(u16, u16),
34 FocusGained,
36 FocusLost,
38 Paste(String),
40}
41
42pub 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 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 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
84fn 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#[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 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}