Skip to main content

presentar_terminal/
input.rs

1//! Input handling for terminal applications.
2
3use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers};
4use presentar_core::{Event, Key, MouseButton, Point};
5
6/// Key binding configuration.
7#[derive(Debug, Clone)]
8pub struct KeyBinding {
9    /// Key code.
10    pub code: KeyCode,
11    /// Required modifiers.
12    pub modifiers: KeyModifiers,
13    /// Action name.
14    pub action: String,
15}
16
17impl KeyBinding {
18    /// Create a new key binding.
19    #[must_use]
20    pub fn new(code: KeyCode, modifiers: KeyModifiers, action: impl Into<String>) -> Self {
21        Self {
22            code,
23            modifiers,
24            action: action.into(),
25        }
26    }
27
28    /// Create a simple key binding without modifiers.
29    #[must_use]
30    pub fn simple(code: KeyCode, action: impl Into<String>) -> Self {
31        Self::new(code, KeyModifiers::NONE, action)
32    }
33
34    /// Check if this binding matches a key event.
35    #[must_use]
36    pub fn matches(&self, event: &KeyEvent) -> bool {
37        event.code == self.code && event.modifiers.contains(self.modifiers)
38    }
39}
40
41/// Input handler for converting crossterm events to presentar events.
42#[derive(Debug, Default)]
43pub struct InputHandler {
44    bindings: Vec<KeyBinding>,
45}
46
47impl InputHandler {
48    /// Create a new input handler.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Add a key binding.
55    pub fn add_binding(&mut self, binding: KeyBinding) {
56        self.bindings.push(binding);
57    }
58
59    /// Convert a crossterm event to a presentar event.
60    #[must_use]
61    pub fn convert(&self, event: CrosstermEvent) -> Option<Event> {
62        match event {
63            CrosstermEvent::Key(key) => self.convert_key(key),
64            CrosstermEvent::Mouse(mouse) => Some(self.convert_mouse(mouse)),
65            CrosstermEvent::Resize(width, height) => Some(Event::Resize {
66                width: f32::from(width),
67                height: f32::from(height),
68            }),
69            CrosstermEvent::FocusGained => Some(Event::FocusIn),
70            CrosstermEvent::FocusLost => Some(Event::FocusOut),
71            CrosstermEvent::Paste(text) => Some(Event::TextInput { text }),
72        }
73    }
74
75    fn convert_key(&self, key: KeyEvent) -> Option<Event> {
76        let presentar_key = match key.code {
77            KeyCode::Char(ch) => char_to_key(ch)?,
78            KeyCode::F(n) => fn_key(n)?,
79            _ => non_char_key(key.code)?,
80        };
81
82        let modifiers = presentar_core::Modifiers {
83            ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
84            alt: key.modifiers.contains(KeyModifiers::ALT),
85            shift: key.modifiers.contains(KeyModifiers::SHIFT),
86            meta: key.modifiers.contains(KeyModifiers::SUPER),
87        };
88        Some(Event::KeyDown {
89            key: presentar_key,
90            modifiers,
91        })
92    }
93
94    fn convert_mouse(&self, mouse: crossterm::event::MouseEvent) -> Event {
95        use crossterm::event::{MouseButton as CtMouseButton, MouseEventKind};
96
97        let position = Point::new(f32::from(mouse.column), f32::from(mouse.row));
98
99        match mouse.kind {
100            MouseEventKind::Down(button) => Event::MouseDown {
101                position,
102                button: match button {
103                    CtMouseButton::Left => MouseButton::Left,
104                    CtMouseButton::Right => MouseButton::Right,
105                    CtMouseButton::Middle => MouseButton::Middle,
106                },
107            },
108            MouseEventKind::Up(button) => Event::MouseUp {
109                position,
110                button: match button {
111                    CtMouseButton::Left => MouseButton::Left,
112                    CtMouseButton::Right => MouseButton::Right,
113                    CtMouseButton::Middle => MouseButton::Middle,
114                },
115            },
116            MouseEventKind::Moved | MouseEventKind::Drag(_) => Event::MouseMove { position },
117            MouseEventKind::ScrollUp => Event::Scroll {
118                delta_x: 0.0,
119                delta_y: -1.0,
120            },
121            MouseEventKind::ScrollDown => Event::Scroll {
122                delta_x: 0.0,
123                delta_y: 1.0,
124            },
125            MouseEventKind::ScrollLeft => Event::Scroll {
126                delta_x: -1.0,
127                delta_y: 0.0,
128            },
129            MouseEventKind::ScrollRight => Event::Scroll {
130                delta_x: 1.0,
131                delta_y: 0.0,
132            },
133        }
134    }
135
136    /// Find a matching binding for a key event.
137    #[must_use]
138    pub fn find_binding(&self, event: &KeyEvent) -> Option<&KeyBinding> {
139        self.bindings.iter().find(|b| b.matches(event))
140    }
141}
142
143// =============================================================================
144// Key conversion helpers (extracted from convert_key for CB-200 compliance)
145// =============================================================================
146
147/// Sorted table for punctuation/symbol char-to-Key lookups.
148static CHAR_PUNCT_TABLE: &[(char, Key)] = &[
149    (' ', Key::Space),
150    ('\'', Key::Quote),
151    (',', Key::Comma),
152    ('-', Key::Minus),
153    ('.', Key::Period),
154    ('/', Key::Slash),
155    (';', Key::Semicolon),
156    ('=', Key::Equal),
157    ('[', Key::BracketLeft),
158    ('\\', Key::Backslash),
159    (']', Key::BracketRight),
160    ('`', Key::Grave),
161];
162
163/// Convert a character to a presentar Key.
164fn char_to_key(ch: char) -> Option<Key> {
165    match ch {
166        'a' | 'A' => Some(Key::A),
167        'b' | 'B' => Some(Key::B),
168        'c' | 'C' => Some(Key::C),
169        'd' | 'D' => Some(Key::D),
170        'e' | 'E' => Some(Key::E),
171        'f' | 'F' => Some(Key::F),
172        'g' | 'G' => Some(Key::G),
173        'h' | 'H' => Some(Key::H),
174        'i' | 'I' => Some(Key::I),
175        'j' | 'J' => Some(Key::J),
176        'k' | 'K' => Some(Key::K),
177        'l' | 'L' => Some(Key::L),
178        'm' | 'M' => Some(Key::M),
179        'n' | 'N' => Some(Key::N),
180        'o' | 'O' => Some(Key::O),
181        'p' | 'P' => Some(Key::P),
182        'q' | 'Q' => Some(Key::Q),
183        'r' | 'R' => Some(Key::R),
184        's' | 'S' => Some(Key::S),
185        't' | 'T' => Some(Key::T),
186        'u' | 'U' => Some(Key::U),
187        'v' | 'V' => Some(Key::V),
188        'w' | 'W' => Some(Key::W),
189        'x' | 'X' => Some(Key::X),
190        'y' | 'Y' => Some(Key::Y),
191        'z' | 'Z' => Some(Key::Z),
192        '0' => Some(Key::Num0),
193        '1' => Some(Key::Num1),
194        '2' => Some(Key::Num2),
195        '3' => Some(Key::Num3),
196        '4' => Some(Key::Num4),
197        '5' => Some(Key::Num5),
198        '6' => Some(Key::Num6),
199        '7' => Some(Key::Num7),
200        '8' => Some(Key::Num8),
201        '9' => Some(Key::Num9),
202        _ => CHAR_PUNCT_TABLE
203            .binary_search_by_key(&ch, |&(c, _)| c)
204            .map(|i| CHAR_PUNCT_TABLE[i].1)
205            .ok(),
206    }
207}
208
209/// Convert function key number to presentar Key.
210fn fn_key(n: u8) -> Option<Key> {
211    static FN_KEYS: [Key; 12] = [
212        Key::F1,
213        Key::F2,
214        Key::F3,
215        Key::F4,
216        Key::F5,
217        Key::F6,
218        Key::F7,
219        Key::F8,
220        Key::F9,
221        Key::F10,
222        Key::F11,
223        Key::F12,
224    ];
225    FN_KEYS.get(n.wrapping_sub(1) as usize).copied()
226}
227
228/// Convert non-char KeyCode to presentar Key.
229fn non_char_key(code: KeyCode) -> Option<Key> {
230    Some(match code {
231        KeyCode::Enter => Key::Enter,
232        KeyCode::Esc => Key::Escape,
233        KeyCode::Backspace => Key::Backspace,
234        KeyCode::Tab => Key::Tab,
235        KeyCode::Delete => Key::Delete,
236        KeyCode::Insert => Key::Insert,
237        KeyCode::Up => Key::Up,
238        KeyCode::Down => Key::Down,
239        KeyCode::Left => Key::Left,
240        KeyCode::Right => Key::Right,
241        KeyCode::Home => Key::Home,
242        KeyCode::End => Key::End,
243        KeyCode::PageUp => Key::PageUp,
244        KeyCode::PageDown => Key::PageDown,
245        _ => return None,
246    })
247}
248
249#[cfg(test)]
250#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
251mod tests {
252    use super::*;
253    use crossterm::event::{MouseButton as CtMouseButton, MouseEvent, MouseEventKind};
254
255    #[test]
256    fn test_key_binding_simple() {
257        let binding = KeyBinding::simple(KeyCode::Char('q'), "quit");
258        assert_eq!(binding.action, "quit");
259        assert_eq!(binding.modifiers, KeyModifiers::NONE);
260    }
261
262    #[test]
263    fn test_key_binding_with_modifiers() {
264        let binding = KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL, "copy");
265        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
266        assert!(binding.matches(&event));
267    }
268
269    #[test]
270    fn test_key_binding_no_match() {
271        let binding = KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL, "copy");
272        let event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
273        assert!(!binding.matches(&event));
274    }
275
276    #[test]
277    fn test_input_handler_add_binding() {
278        let mut handler = InputHandler::new();
279        handler.add_binding(KeyBinding::simple(KeyCode::Char('q'), "quit"));
280        assert_eq!(handler.bindings.len(), 1);
281    }
282
283    #[test]
284    fn test_input_handler_find_binding() {
285        let mut handler = InputHandler::new();
286        handler.add_binding(KeyBinding::simple(KeyCode::Char('q'), "quit"));
287        handler.add_binding(KeyBinding::new(
288            KeyCode::Char('s'),
289            KeyModifiers::CONTROL,
290            "save",
291        ));
292
293        let event = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
294        let binding = handler.find_binding(&event);
295        assert!(binding.is_some());
296        assert_eq!(binding.unwrap().action, "quit");
297
298        let event2 = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
299        assert!(handler.find_binding(&event2).is_none());
300    }
301
302    #[test]
303    fn test_convert_letter_keys() {
304        let handler = InputHandler::new();
305        for ch in 'a'..='z' {
306            let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
307            let result = handler.convert(event);
308            assert!(result.is_some());
309        }
310        for ch in 'A'..='Z' {
311            let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
312            let result = handler.convert(event);
313            assert!(result.is_some());
314        }
315    }
316
317    #[test]
318    fn test_convert_number_keys() {
319        let handler = InputHandler::new();
320        for ch in '0'..='9' {
321            let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
322            let result = handler.convert(event);
323            assert!(result.is_some());
324        }
325    }
326
327    #[test]
328    fn test_convert_special_keys() {
329        let handler = InputHandler::new();
330        let special_keys = [
331            KeyCode::Enter,
332            KeyCode::Esc,
333            KeyCode::Backspace,
334            KeyCode::Tab,
335            KeyCode::Delete,
336            KeyCode::Insert,
337            KeyCode::Up,
338            KeyCode::Down,
339            KeyCode::Left,
340            KeyCode::Right,
341            KeyCode::Home,
342            KeyCode::End,
343            KeyCode::PageUp,
344            KeyCode::PageDown,
345        ];
346        for key in special_keys {
347            let event = CrosstermEvent::Key(KeyEvent::new(key, KeyModifiers::NONE));
348            let result = handler.convert(event);
349            assert!(result.is_some(), "Failed for {:?}", key);
350        }
351    }
352
353    #[test]
354    fn test_convert_function_keys() {
355        let handler = InputHandler::new();
356        for n in 1..=12 {
357            let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::F(n), KeyModifiers::NONE));
358            let result = handler.convert(event);
359            assert!(result.is_some(), "Failed for F{}", n);
360        }
361    }
362
363    #[test]
364    fn test_convert_punctuation_keys() {
365        let handler = InputHandler::new();
366        let punct = [' ', '-', '=', '[', ']', '\\', ';', '\'', '`', ',', '.', '/'];
367        for ch in punct {
368            let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
369            let result = handler.convert(event);
370            assert!(result.is_some(), "Failed for {:?}", ch);
371        }
372    }
373
374    #[test]
375    fn test_convert_unknown_key() {
376        let handler = InputHandler::new();
377        let event = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('£'), KeyModifiers::NONE));
378        let result = handler.convert(event);
379        assert!(result.is_none());
380    }
381
382    #[test]
383    fn test_convert_mouse_down() {
384        let handler = InputHandler::new();
385        let mouse = MouseEvent {
386            kind: MouseEventKind::Down(CtMouseButton::Left),
387            column: 10,
388            row: 5,
389            modifiers: KeyModifiers::NONE,
390        };
391        let event = CrosstermEvent::Mouse(mouse);
392        let result = handler.convert(event).unwrap();
393        assert!(
394            matches!(result, Event::MouseDown { position, button: MouseButton::Left } if position.x == 10.0 && position.y == 5.0)
395        );
396    }
397
398    #[test]
399    fn test_convert_mouse_up() {
400        let handler = InputHandler::new();
401        let mouse = MouseEvent {
402            kind: MouseEventKind::Up(CtMouseButton::Right),
403            column: 15,
404            row: 8,
405            modifiers: KeyModifiers::NONE,
406        };
407        let event = CrosstermEvent::Mouse(mouse);
408        let result = handler.convert(event).unwrap();
409        assert!(matches!(
410            result,
411            Event::MouseUp {
412                button: MouseButton::Right,
413                ..
414            }
415        ));
416    }
417
418    #[test]
419    fn test_convert_mouse_middle() {
420        let handler = InputHandler::new();
421        let mouse = MouseEvent {
422            kind: MouseEventKind::Down(CtMouseButton::Middle),
423            column: 0,
424            row: 0,
425            modifiers: KeyModifiers::NONE,
426        };
427        let event = CrosstermEvent::Mouse(mouse);
428        let result = handler.convert(event).unwrap();
429        assert!(matches!(
430            result,
431            Event::MouseDown {
432                button: MouseButton::Middle,
433                ..
434            }
435        ));
436    }
437
438    #[test]
439    fn test_convert_mouse_move() {
440        let handler = InputHandler::new();
441        let mouse = MouseEvent {
442            kind: MouseEventKind::Moved,
443            column: 20,
444            row: 10,
445            modifiers: KeyModifiers::NONE,
446        };
447        let event = CrosstermEvent::Mouse(mouse);
448        let result = handler.convert(event).unwrap();
449        assert!(matches!(result, Event::MouseMove { position } if position.x == 20.0));
450    }
451
452    #[test]
453    fn test_convert_mouse_drag() {
454        let handler = InputHandler::new();
455        let mouse = MouseEvent {
456            kind: MouseEventKind::Drag(CtMouseButton::Left),
457            column: 25,
458            row: 12,
459            modifiers: KeyModifiers::NONE,
460        };
461        let event = CrosstermEvent::Mouse(mouse);
462        let result = handler.convert(event).unwrap();
463        assert!(matches!(result, Event::MouseMove { .. }));
464    }
465
466    #[test]
467    fn test_convert_scroll_events() {
468        let handler = InputHandler::new();
469
470        let scroll_up = MouseEvent {
471            kind: MouseEventKind::ScrollUp,
472            column: 0,
473            row: 0,
474            modifiers: KeyModifiers::NONE,
475        };
476        let result = handler.convert(CrosstermEvent::Mouse(scroll_up)).unwrap();
477        assert!(matches!(result, Event::Scroll { delta_y, .. } if delta_y < 0.0));
478
479        let scroll_down = MouseEvent {
480            kind: MouseEventKind::ScrollDown,
481            column: 0,
482            row: 0,
483            modifiers: KeyModifiers::NONE,
484        };
485        let result = handler.convert(CrosstermEvent::Mouse(scroll_down)).unwrap();
486        assert!(matches!(result, Event::Scroll { delta_y, .. } if delta_y > 0.0));
487
488        let scroll_left = MouseEvent {
489            kind: MouseEventKind::ScrollLeft,
490            column: 0,
491            row: 0,
492            modifiers: KeyModifiers::NONE,
493        };
494        let result = handler.convert(CrosstermEvent::Mouse(scroll_left)).unwrap();
495        assert!(matches!(result, Event::Scroll { delta_x, .. } if delta_x < 0.0));
496
497        let scroll_right = MouseEvent {
498            kind: MouseEventKind::ScrollRight,
499            column: 0,
500            row: 0,
501            modifiers: KeyModifiers::NONE,
502        };
503        let result = handler
504            .convert(CrosstermEvent::Mouse(scroll_right))
505            .unwrap();
506        assert!(matches!(result, Event::Scroll { delta_x, .. } if delta_x > 0.0));
507    }
508
509    #[test]
510    fn test_convert_resize() {
511        let handler = InputHandler::new();
512        let event = CrosstermEvent::Resize(120, 40);
513        let result = handler.convert(event).unwrap();
514        assert!(
515            matches!(result, Event::Resize { width, height } if width == 120.0 && height == 40.0)
516        );
517    }
518
519    #[test]
520    fn test_convert_focus_events() {
521        let handler = InputHandler::new();
522
523        let result = handler.convert(CrosstermEvent::FocusGained).unwrap();
524        assert!(matches!(result, Event::FocusIn));
525
526        let result = handler.convert(CrosstermEvent::FocusLost).unwrap();
527        assert!(matches!(result, Event::FocusOut));
528    }
529
530    #[test]
531    fn test_convert_paste() {
532        let handler = InputHandler::new();
533        let event = CrosstermEvent::Paste("hello world".to_string());
534        let result = handler.convert(event).unwrap();
535        assert!(matches!(result, Event::TextInput { text } if text == "hello world"));
536    }
537}