Skip to main content

kbd_crossterm/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Crossterm key event conversions for `kbd`.
4//!
5//! This crate converts crossterm's key events into `kbd`'s unified types
6//! so you can use `kbd`'s [`Dispatcher`](kbd::dispatcher::Dispatcher),
7//! hotkey parsing, layers, and sequences in a TUI app.
8//!
9//! Crossterm reports keys as characters (`Char('a')`) and modifier
10//! bitflags, while `kbd` uses physical key positions (`Key::A`) and
11//! typed `Modifier` values.
12//!
13//! # Extension traits
14//!
15//! - [`CrosstermKeyExt`] — converts a [`crossterm::event::KeyCode`] to a
16//!   [`kbd::key::Key`].
17//! - [`CrosstermModifiersExt`] — converts [`crossterm::event::KeyModifiers`]
18//!   to a `Vec<Modifier>`.
19//! - [`CrosstermEventExt`] — converts a full [`crossterm::event::KeyEvent`]
20//!   to a [`kbd::hotkey::Hotkey`].
21//!
22//! # Key mapping
23//!
24//! | Crossterm | kbd | Notes |
25//! |---|---|---|
26//! | `Char('a')` – `Char('z')` | [`Key::A`] – [`Key::Z`] | Case-insensitive |
27//! | `Char('0')` – `Char('9')` | [`Key::DIGIT0`] – [`Key::DIGIT9`] | |
28//! | `Char('-')`, `Char('=')`, … | [`Key::MINUS`], [`Key::EQUAL`], … | Physical position |
29//! | `F(1)` – `F(35)` | [`Key::F1`] – [`Key::F35`] | `F(0)` and `F(36+)` → `None` |
30//! | `Enter`, `Esc`, `Tab`, … | [`Key::ENTER`], [`Key::ESCAPE`], [`Key::TAB`], … | Named keys |
31//! | `Media(PlayPause)`, … | [`Key::MEDIA_PLAY_PAUSE`], … | Media keys |
32//! | `Modifier(LeftControl)`, … | [`Key::CONTROL_LEFT`], … | Modifier keys as triggers |
33//! | `BackTab`, `Null`, `KeypadBegin` | `None` | No `kbd` equivalent |
34//! | Non-ASCII `Char` (e.g., `'é'`) | `None` | No physical key mapping |
35//!
36//! # Modifier mapping
37//!
38//! | Crossterm | kbd |
39//! |---|---|
40//! | `CONTROL` | [`Modifier::Ctrl`] |
41//! | `SHIFT` | [`Modifier::Shift`] |
42//! | `ALT` | [`Modifier::Alt`] |
43//! | `SUPER` | [`Modifier::Super`] |
44//! | `HYPER`, `META` | *(ignored)* |
45//!
46//! # Usage
47//!
48//! ```
49//! use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
50//! use kbd::prelude::*;
51//! use kbd_crossterm::{CrosstermEventExt, CrosstermKeyExt, CrosstermModifiersExt};
52//!
53//! // Single key conversion
54//! let key = KeyCode::Char('a').to_key();
55//! assert_eq!(key, Some(Key::A));
56//!
57//! // Modifier conversion
58//! let mods = KeyModifiers::CONTROL.to_modifiers();
59//! assert_eq!(mods, vec![Modifier::Ctrl]);
60//!
61//! // Full event conversion
62//! let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
63//! let hotkey = event.to_hotkey();
64//! assert_eq!(hotkey, Some(Hotkey::new(Key::C).modifier(Modifier::Ctrl)));
65//! ```
66
67use crossterm::event::KeyCode;
68use crossterm::event::KeyEvent;
69use crossterm::event::KeyModifiers;
70use crossterm::event::MediaKeyCode;
71use crossterm::event::ModifierKeyCode;
72use kbd::hotkey::Hotkey;
73use kbd::hotkey::Modifier;
74use kbd::key::Key;
75
76mod private {
77    pub trait Sealed {}
78    impl Sealed for crossterm::event::KeyCode {}
79    impl Sealed for crossterm::event::KeyModifiers {}
80    impl Sealed for crossterm::event::KeyEvent {}
81}
82
83/// Convert a crossterm [`KeyCode`] to a `kbd` [`Key`].
84///
85/// Returns `None` for keys that have no `kbd` equivalent (e.g.,
86/// `BackTab`, `Null`, `KeypadBegin`, non-ASCII characters).
87///
88/// This trait is sealed and cannot be implemented outside this crate.
89pub trait CrosstermKeyExt: private::Sealed {
90    /// Convert this key code to a `kbd` [`Key`], or `None` if unmappable.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use crossterm::event::KeyCode;
96    /// use kbd::prelude::*;
97    /// use kbd_crossterm::CrosstermKeyExt;
98    ///
99    /// assert_eq!(KeyCode::Char('a').to_key(), Some(Key::A));
100    /// assert_eq!(KeyCode::F(5).to_key(), Some(Key::F5));
101    /// assert_eq!(KeyCode::Null.to_key(), None);
102    /// ```
103    #[must_use]
104    fn to_key(&self) -> Option<Key>;
105}
106
107impl CrosstermKeyExt for KeyCode {
108    fn to_key(&self) -> Option<Key> {
109        match self {
110            KeyCode::Char(ch) => char_to_key(*ch),
111            KeyCode::F(n) => function_key(*n),
112            KeyCode::Enter => Some(Key::ENTER),
113            KeyCode::Esc => Some(Key::ESCAPE),
114            KeyCode::Backspace => Some(Key::BACKSPACE),
115            KeyCode::Tab => Some(Key::TAB),
116            KeyCode::Delete => Some(Key::DELETE),
117            KeyCode::Insert => Some(Key::INSERT),
118            KeyCode::Home => Some(Key::HOME),
119            KeyCode::End => Some(Key::END),
120            KeyCode::PageUp => Some(Key::PAGE_UP),
121            KeyCode::PageDown => Some(Key::PAGE_DOWN),
122            KeyCode::Up => Some(Key::ARROW_UP),
123            KeyCode::Down => Some(Key::ARROW_DOWN),
124            KeyCode::Left => Some(Key::ARROW_LEFT),
125            KeyCode::Right => Some(Key::ARROW_RIGHT),
126            KeyCode::CapsLock => Some(Key::CAPS_LOCK),
127            KeyCode::ScrollLock => Some(Key::SCROLL_LOCK),
128            KeyCode::NumLock => Some(Key::NUM_LOCK),
129            KeyCode::PrintScreen => Some(Key::PRINT_SCREEN),
130            KeyCode::Pause => Some(Key::PAUSE),
131            KeyCode::Menu => Some(Key::CONTEXT_MENU),
132            KeyCode::Media(media) => media_to_key(*media),
133            KeyCode::Modifier(modifier) => modifier_keycode_to_key(*modifier),
134            KeyCode::BackTab | KeyCode::Null | KeyCode::KeypadBegin => None,
135        }
136    }
137}
138
139/// Convert crossterm [`KeyModifiers`] bitflags to a sorted `Vec<Modifier>`.
140///
141/// Crossterm's `HYPER` and `META` flags have no `kbd` equivalent and
142/// are silently ignored.
143///
144/// This trait is sealed and cannot be implemented outside this crate.
145pub trait CrosstermModifiersExt: private::Sealed {
146    /// Convert these modifier flags to a `Vec<Modifier>`.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use crossterm::event::KeyModifiers;
152    /// use kbd::prelude::*;
153    /// use kbd_crossterm::CrosstermModifiersExt;
154    ///
155    /// let mods = (KeyModifiers::CONTROL | KeyModifiers::SHIFT).to_modifiers();
156    /// assert_eq!(mods, vec![Modifier::Ctrl, Modifier::Shift]);
157    /// ```
158    #[must_use]
159    fn to_modifiers(&self) -> Vec<Modifier>;
160}
161
162impl CrosstermModifiersExt for KeyModifiers {
163    fn to_modifiers(&self) -> Vec<Modifier> {
164        Modifier::collect_active([
165            (self.contains(KeyModifiers::CONTROL), Modifier::Ctrl),
166            (self.contains(KeyModifiers::SHIFT), Modifier::Shift),
167            (self.contains(KeyModifiers::ALT), Modifier::Alt),
168            (self.contains(KeyModifiers::SUPER), Modifier::Super),
169        ])
170    }
171}
172
173/// Convert a crossterm [`KeyEvent`] to a `kbd` [`Hotkey`].
174///
175/// Returns `None` if the key code has no `kbd` equivalent.
176///
177/// When the key is itself a modifier (e.g., `LeftShift`), the corresponding
178/// modifier flag is stripped from the modifiers — crossterm includes the
179/// pressed modifier key in its own modifier bitflags, but `kbd` treats
180/// the key as the trigger, not as a modifier of itself.
181///
182/// This trait is sealed and cannot be implemented outside this crate.
183pub trait CrosstermEventExt: private::Sealed {
184    /// Convert this key event to a [`Hotkey`], or `None` if the key is unmappable.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
190    /// use kbd::prelude::*;
191    /// use kbd_crossterm::CrosstermEventExt;
192    ///
193    /// let event = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
194    /// assert_eq!(
195    ///     event.to_hotkey(),
196    ///     Some(Hotkey::new(Key::S).modifier(Modifier::Ctrl)),
197    /// );
198    /// ```
199    #[must_use]
200    fn to_hotkey(&self) -> Option<Hotkey>;
201}
202
203impl CrosstermEventExt for KeyEvent {
204    fn to_hotkey(&self) -> Option<Hotkey> {
205        let key = self.code.to_key()?;
206        let mut flags = self.modifiers;
207
208        // Strip the modifier that corresponds to the key itself.
209        // When pressing LeftShift, crossterm reports SHIFT in the modifiers,
210        // but we want the hotkey to be just "ShiftLeft", not "Shift+ShiftLeft".
211        if let Some(self_modifier) = modifier_keycode_flag(self.code) {
212            flags.remove(self_modifier);
213        }
214
215        let modifiers = flags.to_modifiers();
216        Some(Hotkey::with_modifiers(key, modifiers))
217    }
218}
219
220fn char_to_key(ch: char) -> Option<Key> {
221    match ch.to_ascii_uppercase() {
222        'A' => Some(Key::A),
223        'B' => Some(Key::B),
224        'C' => Some(Key::C),
225        'D' => Some(Key::D),
226        'E' => Some(Key::E),
227        'F' => Some(Key::F),
228        'G' => Some(Key::G),
229        'H' => Some(Key::H),
230        'I' => Some(Key::I),
231        'J' => Some(Key::J),
232        'K' => Some(Key::K),
233        'L' => Some(Key::L),
234        'M' => Some(Key::M),
235        'N' => Some(Key::N),
236        'O' => Some(Key::O),
237        'P' => Some(Key::P),
238        'Q' => Some(Key::Q),
239        'R' => Some(Key::R),
240        'S' => Some(Key::S),
241        'T' => Some(Key::T),
242        'U' => Some(Key::U),
243        'V' => Some(Key::V),
244        'W' => Some(Key::W),
245        'X' => Some(Key::X),
246        'Y' => Some(Key::Y),
247        'Z' => Some(Key::Z),
248        '0' => Some(Key::DIGIT0),
249        '1' => Some(Key::DIGIT1),
250        '2' => Some(Key::DIGIT2),
251        '3' => Some(Key::DIGIT3),
252        '4' => Some(Key::DIGIT4),
253        '5' => Some(Key::DIGIT5),
254        '6' => Some(Key::DIGIT6),
255        '7' => Some(Key::DIGIT7),
256        '8' => Some(Key::DIGIT8),
257        '9' => Some(Key::DIGIT9),
258        ' ' => Some(Key::SPACE),
259        '-' => Some(Key::MINUS),
260        '=' => Some(Key::EQUAL),
261        '[' => Some(Key::BRACKET_LEFT),
262        ']' => Some(Key::BRACKET_RIGHT),
263        '\\' => Some(Key::BACKSLASH),
264        ';' => Some(Key::SEMICOLON),
265        '\'' => Some(Key::QUOTE),
266        '`' => Some(Key::BACKQUOTE),
267        ',' => Some(Key::COMMA),
268        '.' => Some(Key::PERIOD),
269        '/' => Some(Key::SLASH),
270        _ => None,
271    }
272}
273
274fn function_key(n: u8) -> Option<Key> {
275    match n {
276        1 => Some(Key::F1),
277        2 => Some(Key::F2),
278        3 => Some(Key::F3),
279        4 => Some(Key::F4),
280        5 => Some(Key::F5),
281        6 => Some(Key::F6),
282        7 => Some(Key::F7),
283        8 => Some(Key::F8),
284        9 => Some(Key::F9),
285        10 => Some(Key::F10),
286        11 => Some(Key::F11),
287        12 => Some(Key::F12),
288        13 => Some(Key::F13),
289        14 => Some(Key::F14),
290        15 => Some(Key::F15),
291        16 => Some(Key::F16),
292        17 => Some(Key::F17),
293        18 => Some(Key::F18),
294        19 => Some(Key::F19),
295        20 => Some(Key::F20),
296        21 => Some(Key::F21),
297        22 => Some(Key::F22),
298        23 => Some(Key::F23),
299        24 => Some(Key::F24),
300        25 => Some(Key::F25),
301        26 => Some(Key::F26),
302        27 => Some(Key::F27),
303        28 => Some(Key::F28),
304        29 => Some(Key::F29),
305        30 => Some(Key::F30),
306        31 => Some(Key::F31),
307        32 => Some(Key::F32),
308        33 => Some(Key::F33),
309        34 => Some(Key::F34),
310        35 => Some(Key::F35),
311        _ => None,
312    }
313}
314
315fn media_to_key(media: MediaKeyCode) -> Option<Key> {
316    match media {
317        MediaKeyCode::PlayPause => Some(Key::MEDIA_PLAY_PAUSE),
318        MediaKeyCode::Stop => Some(Key::MEDIA_STOP),
319        MediaKeyCode::TrackNext => Some(Key::MEDIA_TRACK_NEXT),
320        MediaKeyCode::TrackPrevious => Some(Key::MEDIA_TRACK_PREVIOUS),
321        MediaKeyCode::RaiseVolume => Some(Key::AUDIO_VOLUME_UP),
322        MediaKeyCode::LowerVolume => Some(Key::AUDIO_VOLUME_DOWN),
323        MediaKeyCode::MuteVolume => Some(Key::AUDIO_VOLUME_MUTE),
324        MediaKeyCode::Play => Some(Key::MEDIA_PLAY),
325        MediaKeyCode::Pause => Some(Key::MEDIA_PAUSE),
326        MediaKeyCode::FastForward => Some(Key::MEDIA_FAST_FORWARD),
327        MediaKeyCode::Rewind => Some(Key::MEDIA_REWIND),
328        MediaKeyCode::Record => Some(Key::MEDIA_RECORD),
329        MediaKeyCode::Reverse => None,
330    }
331}
332
333fn modifier_keycode_to_key(modifier: ModifierKeyCode) -> Option<Key> {
334    match modifier {
335        ModifierKeyCode::LeftControl => Some(Key::CONTROL_LEFT),
336        ModifierKeyCode::RightControl => Some(Key::CONTROL_RIGHT),
337        ModifierKeyCode::LeftShift => Some(Key::SHIFT_LEFT),
338        ModifierKeyCode::RightShift => Some(Key::SHIFT_RIGHT),
339        ModifierKeyCode::LeftAlt => Some(Key::ALT_LEFT),
340        ModifierKeyCode::RightAlt => Some(Key::ALT_RIGHT),
341        ModifierKeyCode::LeftSuper => Some(Key::META_LEFT),
342        ModifierKeyCode::RightSuper => Some(Key::META_RIGHT),
343        ModifierKeyCode::LeftHyper | ModifierKeyCode::RightHyper => Some(Key::HYPER),
344        ModifierKeyCode::LeftMeta
345        | ModifierKeyCode::RightMeta
346        | ModifierKeyCode::IsoLevel3Shift
347        | ModifierKeyCode::IsoLevel5Shift => None,
348    }
349}
350
351/// Returns the `KeyModifiers` flag that corresponds to a modifier `KeyCode`,
352/// so we can strip it when the key itself IS the modifier.
353fn modifier_keycode_flag(code: KeyCode) -> Option<KeyModifiers> {
354    match code {
355        KeyCode::Modifier(ModifierKeyCode::LeftControl | ModifierKeyCode::RightControl) => {
356            Some(KeyModifiers::CONTROL)
357        }
358        KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift) => {
359            Some(KeyModifiers::SHIFT)
360        }
361        KeyCode::Modifier(ModifierKeyCode::LeftAlt | ModifierKeyCode::RightAlt) => {
362            Some(KeyModifiers::ALT)
363        }
364        KeyCode::Modifier(ModifierKeyCode::LeftSuper | ModifierKeyCode::RightSuper) => {
365            Some(KeyModifiers::SUPER)
366        }
367        _ => None,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use crossterm::event::KeyCode;
374    use crossterm::event::KeyEvent;
375    use crossterm::event::KeyModifiers;
376    use crossterm::event::MediaKeyCode;
377    use crossterm::event::ModifierKeyCode;
378    use kbd::hotkey::Hotkey;
379    use kbd::hotkey::Modifier;
380    use kbd::key::Key;
381
382    use super::*;
383
384    // CrosstermKeyExt tests
385
386    #[test]
387    fn char_lowercase_to_key() {
388        assert_eq!(KeyCode::Char('a').to_key(), Some(Key::A));
389        assert_eq!(KeyCode::Char('z').to_key(), Some(Key::Z));
390    }
391
392    #[test]
393    fn char_uppercase_to_key() {
394        assert_eq!(KeyCode::Char('A').to_key(), Some(Key::A));
395        assert_eq!(KeyCode::Char('Z').to_key(), Some(Key::Z));
396    }
397
398    #[test]
399    fn digit_chars_to_key() {
400        assert_eq!(KeyCode::Char('0').to_key(), Some(Key::DIGIT0));
401        assert_eq!(KeyCode::Char('9').to_key(), Some(Key::DIGIT9));
402    }
403
404    #[test]
405    fn punctuation_chars_to_key() {
406        assert_eq!(KeyCode::Char('-').to_key(), Some(Key::MINUS));
407        assert_eq!(KeyCode::Char('=').to_key(), Some(Key::EQUAL));
408        assert_eq!(KeyCode::Char('[').to_key(), Some(Key::BRACKET_LEFT));
409        assert_eq!(KeyCode::Char(']').to_key(), Some(Key::BRACKET_RIGHT));
410        assert_eq!(KeyCode::Char('\\').to_key(), Some(Key::BACKSLASH));
411        assert_eq!(KeyCode::Char(';').to_key(), Some(Key::SEMICOLON));
412        assert_eq!(KeyCode::Char('\'').to_key(), Some(Key::QUOTE));
413        assert_eq!(KeyCode::Char('`').to_key(), Some(Key::BACKQUOTE));
414        assert_eq!(KeyCode::Char(',').to_key(), Some(Key::COMMA));
415        assert_eq!(KeyCode::Char('.').to_key(), Some(Key::PERIOD));
416        assert_eq!(KeyCode::Char('/').to_key(), Some(Key::SLASH));
417    }
418
419    #[test]
420    fn named_keys_to_key() {
421        assert_eq!(KeyCode::Enter.to_key(), Some(Key::ENTER));
422        assert_eq!(KeyCode::Esc.to_key(), Some(Key::ESCAPE));
423        assert_eq!(KeyCode::Backspace.to_key(), Some(Key::BACKSPACE));
424        assert_eq!(KeyCode::Tab.to_key(), Some(Key::TAB));
425        assert_eq!(KeyCode::Delete.to_key(), Some(Key::DELETE));
426        assert_eq!(KeyCode::Insert.to_key(), Some(Key::INSERT));
427        assert_eq!(KeyCode::Home.to_key(), Some(Key::HOME));
428        assert_eq!(KeyCode::End.to_key(), Some(Key::END));
429        assert_eq!(KeyCode::PageUp.to_key(), Some(Key::PAGE_UP));
430        assert_eq!(KeyCode::PageDown.to_key(), Some(Key::PAGE_DOWN));
431        assert_eq!(KeyCode::Up.to_key(), Some(Key::ARROW_UP));
432        assert_eq!(KeyCode::Down.to_key(), Some(Key::ARROW_DOWN));
433        assert_eq!(KeyCode::Left.to_key(), Some(Key::ARROW_LEFT));
434        assert_eq!(KeyCode::Right.to_key(), Some(Key::ARROW_RIGHT));
435        assert_eq!(KeyCode::CapsLock.to_key(), Some(Key::CAPS_LOCK));
436        assert_eq!(KeyCode::ScrollLock.to_key(), Some(Key::SCROLL_LOCK));
437        assert_eq!(KeyCode::NumLock.to_key(), Some(Key::NUM_LOCK));
438        assert_eq!(KeyCode::PrintScreen.to_key(), Some(Key::PRINT_SCREEN));
439        assert_eq!(KeyCode::Pause.to_key(), Some(Key::PAUSE));
440        assert_eq!(KeyCode::Menu.to_key(), Some(Key::CONTEXT_MENU));
441    }
442
443    #[test]
444    fn function_keys_to_key() {
445        assert_eq!(KeyCode::F(1).to_key(), Some(Key::F1));
446        assert_eq!(KeyCode::F(12).to_key(), Some(Key::F12));
447        assert_eq!(KeyCode::F(24).to_key(), Some(Key::F24));
448        assert_eq!(KeyCode::F(25).to_key(), Some(Key::F25));
449        assert_eq!(KeyCode::F(35).to_key(), Some(Key::F35));
450        assert_eq!(KeyCode::F(36).to_key(), None);
451        assert_eq!(KeyCode::F(0).to_key(), None);
452    }
453
454    #[test]
455    fn media_keys_to_key() {
456        assert_eq!(
457            KeyCode::Media(MediaKeyCode::PlayPause).to_key(),
458            Some(Key::MEDIA_PLAY_PAUSE)
459        );
460        assert_eq!(
461            KeyCode::Media(MediaKeyCode::Stop).to_key(),
462            Some(Key::MEDIA_STOP)
463        );
464        assert_eq!(
465            KeyCode::Media(MediaKeyCode::TrackNext).to_key(),
466            Some(Key::MEDIA_TRACK_NEXT)
467        );
468        assert_eq!(
469            KeyCode::Media(MediaKeyCode::TrackPrevious).to_key(),
470            Some(Key::MEDIA_TRACK_PREVIOUS)
471        );
472        assert_eq!(
473            KeyCode::Media(MediaKeyCode::RaiseVolume).to_key(),
474            Some(Key::AUDIO_VOLUME_UP)
475        );
476        assert_eq!(
477            KeyCode::Media(MediaKeyCode::LowerVolume).to_key(),
478            Some(Key::AUDIO_VOLUME_DOWN)
479        );
480        assert_eq!(
481            KeyCode::Media(MediaKeyCode::MuteVolume).to_key(),
482            Some(Key::AUDIO_VOLUME_MUTE)
483        );
484    }
485
486    #[test]
487    fn extended_media_keys_to_key() {
488        assert_eq!(
489            KeyCode::Media(MediaKeyCode::Play).to_key(),
490            Some(Key::MEDIA_PLAY)
491        );
492        assert_eq!(
493            KeyCode::Media(MediaKeyCode::Pause).to_key(),
494            Some(Key::MEDIA_PAUSE)
495        );
496        assert_eq!(
497            KeyCode::Media(MediaKeyCode::FastForward).to_key(),
498            Some(Key::MEDIA_FAST_FORWARD)
499        );
500        assert_eq!(
501            KeyCode::Media(MediaKeyCode::Rewind).to_key(),
502            Some(Key::MEDIA_REWIND)
503        );
504        assert_eq!(
505            KeyCode::Media(MediaKeyCode::Record).to_key(),
506            Some(Key::MEDIA_RECORD)
507        );
508    }
509
510    #[test]
511    fn modifier_keycode_to_key() {
512        assert_eq!(
513            KeyCode::Modifier(ModifierKeyCode::LeftControl).to_key(),
514            Some(Key::CONTROL_LEFT)
515        );
516        assert_eq!(
517            KeyCode::Modifier(ModifierKeyCode::RightControl).to_key(),
518            Some(Key::CONTROL_RIGHT)
519        );
520        assert_eq!(
521            KeyCode::Modifier(ModifierKeyCode::LeftShift).to_key(),
522            Some(Key::SHIFT_LEFT)
523        );
524        assert_eq!(
525            KeyCode::Modifier(ModifierKeyCode::RightShift).to_key(),
526            Some(Key::SHIFT_RIGHT)
527        );
528        assert_eq!(
529            KeyCode::Modifier(ModifierKeyCode::LeftAlt).to_key(),
530            Some(Key::ALT_LEFT)
531        );
532        assert_eq!(
533            KeyCode::Modifier(ModifierKeyCode::RightAlt).to_key(),
534            Some(Key::ALT_RIGHT)
535        );
536        assert_eq!(
537            KeyCode::Modifier(ModifierKeyCode::LeftSuper).to_key(),
538            Some(Key::META_LEFT)
539        );
540        assert_eq!(
541            KeyCode::Modifier(ModifierKeyCode::RightSuper).to_key(),
542            Some(Key::META_RIGHT)
543        );
544    }
545
546    #[test]
547    fn hyper_modifier_keys_to_key() {
548        assert_eq!(
549            KeyCode::Modifier(ModifierKeyCode::LeftHyper).to_key(),
550            Some(Key::HYPER)
551        );
552        assert_eq!(
553            KeyCode::Modifier(ModifierKeyCode::RightHyper).to_key(),
554            Some(Key::HYPER)
555        );
556    }
557
558    #[test]
559    fn unmappable_keys_return_none() {
560        assert_eq!(KeyCode::Null.to_key(), None);
561        assert_eq!(KeyCode::BackTab.to_key(), None);
562        assert_eq!(KeyCode::KeypadBegin.to_key(), None);
563    }
564
565    #[test]
566    fn non_ascii_chars_return_none() {
567        assert_eq!(KeyCode::Char('é').to_key(), None);
568        assert_eq!(KeyCode::Char('中').to_key(), None);
569    }
570
571    #[test]
572    fn reverse_media_key_returns_none() {
573        assert_eq!(KeyCode::Media(MediaKeyCode::Reverse).to_key(), None);
574    }
575
576    #[test]
577    fn unmappable_modifier_keycodes_return_none() {
578        assert_eq!(KeyCode::Modifier(ModifierKeyCode::LeftMeta).to_key(), None);
579        assert_eq!(KeyCode::Modifier(ModifierKeyCode::RightMeta).to_key(), None);
580        assert_eq!(
581            KeyCode::Modifier(ModifierKeyCode::IsoLevel3Shift).to_key(),
582            None
583        );
584        assert_eq!(
585            KeyCode::Modifier(ModifierKeyCode::IsoLevel5Shift).to_key(),
586            None
587        );
588    }
589
590    // CrosstermModifiersExt tests
591
592    #[test]
593    fn empty_modifiers() {
594        assert_eq!(KeyModifiers::NONE.to_modifiers(), Vec::<Modifier>::new());
595    }
596
597    #[test]
598    fn single_modifier() {
599        assert_eq!(KeyModifiers::CONTROL.to_modifiers(), vec![Modifier::Ctrl]);
600        assert_eq!(KeyModifiers::SHIFT.to_modifiers(), vec![Modifier::Shift]);
601        assert_eq!(KeyModifiers::ALT.to_modifiers(), vec![Modifier::Alt]);
602        assert_eq!(KeyModifiers::SUPER.to_modifiers(), vec![Modifier::Super]);
603    }
604
605    #[test]
606    fn combined_modifiers() {
607        let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
608        let result = mods.to_modifiers();
609        assert_eq!(result, vec![Modifier::Ctrl, Modifier::Shift]);
610    }
611
612    #[test]
613    fn all_modifiers() {
614        let mods =
615            KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::SUPER;
616        let result = mods.to_modifiers();
617        assert_eq!(
618            result,
619            vec![
620                Modifier::Ctrl,
621                Modifier::Shift,
622                Modifier::Alt,
623                Modifier::Super
624            ]
625        );
626    }
627
628    #[test]
629    fn hyper_and_meta_ignored() {
630        let mods = KeyModifiers::HYPER | KeyModifiers::META;
631        assert_eq!(mods.to_modifiers(), Vec::<Modifier>::new());
632    }
633
634    // CrosstermEventExt tests
635
636    #[test]
637    fn simple_key_event_to_hotkey() {
638        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
639        let hotkey = event.to_hotkey();
640        assert_eq!(hotkey, Some(Hotkey::new(Key::C)));
641    }
642
643    #[test]
644    fn key_event_with_modifiers_to_hotkey() {
645        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
646        let hotkey = event.to_hotkey();
647        assert_eq!(hotkey, Some(Hotkey::new(Key::C).modifier(Modifier::Ctrl)));
648    }
649
650    #[test]
651    fn key_event_with_multiple_modifiers() {
652        let event = KeyEvent::new(
653            KeyCode::Char('a'),
654            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
655        );
656        let hotkey = event.to_hotkey();
657        assert_eq!(
658            hotkey,
659            Some(
660                Hotkey::new(Key::A)
661                    .modifier(Modifier::Ctrl)
662                    .modifier(Modifier::Shift)
663            )
664        );
665    }
666
667    #[test]
668    fn unmappable_key_event_returns_none() {
669        let event = KeyEvent::new(KeyCode::Null, KeyModifiers::NONE);
670        assert_eq!(event.to_hotkey(), None);
671    }
672
673    #[test]
674    fn modifier_key_event_strips_self_modifier() {
675        // When crossterm reports pressing LeftShift, the modifiers already include SHIFT.
676        // The hotkey should represent "just Shift pressed" (ShiftLeft with no extra modifiers),
677        // not "Shift+ShiftLeft".
678        let event = KeyEvent::new(
679            KeyCode::Modifier(ModifierKeyCode::LeftShift),
680            KeyModifiers::SHIFT,
681        );
682        let hotkey = event.to_hotkey();
683        assert_eq!(hotkey, Some(Hotkey::new(Key::SHIFT_LEFT)));
684    }
685
686    #[test]
687    fn modifier_key_event_keeps_other_modifiers() {
688        // Pressing Ctrl while Shift is already held
689        let event = KeyEvent::new(
690            KeyCode::Modifier(ModifierKeyCode::LeftControl),
691            KeyModifiers::SHIFT | KeyModifiers::CONTROL,
692        );
693        let hotkey = event.to_hotkey();
694        assert_eq!(
695            hotkey,
696            Some(Hotkey::new(Key::CONTROL_LEFT).modifier(Modifier::Shift))
697        );
698    }
699
700    #[test]
701    fn uppercase_char_treated_as_physical_key() {
702        // crossterm reports 'A' (uppercase) when Shift is held — this is the same
703        // physical key as 'a', just with Shift modifier
704        let event = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
705        let hotkey = event.to_hotkey();
706        assert_eq!(hotkey, Some(Hotkey::new(Key::A).modifier(Modifier::Shift)));
707    }
708
709    #[test]
710    fn space_key_event() {
711        let event = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
712        let hotkey = event.to_hotkey();
713        assert_eq!(hotkey, Some(Hotkey::new(Key::SPACE)));
714    }
715
716    #[test]
717    fn ctrl_shift_f5() {
718        let event = KeyEvent::new(KeyCode::F(5), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
719        let hotkey = event.to_hotkey();
720        assert_eq!(
721            hotkey,
722            Some(
723                Hotkey::new(Key::F5)
724                    .modifier(Modifier::Ctrl)
725                    .modifier(Modifier::Shift)
726            )
727        );
728    }
729}