Skip to main content

par_term_keybindings/
matcher.rs

1//! Key event matching.
2//!
3//! Matches winit KeyEvents against parsed KeyCombos.
4//! Supports both logical key matching (character-based) and physical key matching
5//! (scan code-based) for language-agnostic bindings.
6
7use super::parser::{KeyCombo, Modifiers, ParsedKey};
8use winit::event::{KeyEvent, Modifiers as WinitModifiers};
9use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
10
11/// Matcher for comparing winit key events against keybindings.
12#[derive(Debug)]
13pub struct KeybindingMatcher {
14    /// Active modifiers from the event
15    modifiers: Modifiers,
16    /// The logical key from the event
17    key: Option<MatchKey>,
18    /// The physical key code from the event (for language-agnostic matching)
19    physical_key: Option<KeyCode>,
20}
21
22/// Normalized key for matching purposes.
23#[derive(Debug)]
24enum MatchKey {
25    Character(char),
26    Named(NamedKey),
27}
28
29impl KeybindingMatcher {
30    /// Create a matcher from a winit key event.
31    pub fn from_event(event: &KeyEvent, modifiers: &WinitModifiers) -> Self {
32        let mods = Modifiers {
33            ctrl: modifiers.state().control_key(),
34            alt: modifiers.state().alt_key(),
35            shift: modifiers.state().shift_key(),
36            super_key: modifiers.state().super_key(),
37            cmd_or_ctrl: false, // Resolved during matching
38        };
39
40        let key = match &event.logical_key {
41            Key::Character(c) => {
42                // Get the first character, uppercased for case-insensitive matching
43                c.chars()
44                    .next()
45                    .map(|ch| MatchKey::Character(ch.to_ascii_uppercase()))
46            }
47            Key::Named(named) => Some(MatchKey::Named(*named)),
48            _ => None,
49        };
50
51        // Extract physical key code
52        let physical_key = match event.physical_key {
53            PhysicalKey::Code(code) => Some(code),
54            PhysicalKey::Unidentified(_) => None,
55        };
56
57        Self {
58            modifiers: mods,
59            key,
60            physical_key,
61        }
62    }
63
64    /// Create a matcher from a winit key event with remapped modifiers.
65    ///
66    /// This applies modifier remapping before matching, allowing users to customize
67    /// which physical keys act as which modifiers.
68    pub fn from_event_with_remapping(
69        event: &KeyEvent,
70        modifiers: &WinitModifiers,
71        remapping: &par_term_config::ModifierRemapping,
72    ) -> Self {
73        use par_term_config::ModifierTarget;
74
75        // Start with the raw modifier state
76        let mut ctrl = modifiers.state().control_key();
77        let mut alt = modifiers.state().alt_key();
78        let mut shift = modifiers.state().shift_key();
79        let mut super_key = modifiers.state().super_key();
80
81        // Apply remapping based on which physical modifier keys are pressed
82        // We need to check which specific modifier keys are pressed and remap them
83
84        // Helper to apply a remap: if target is set, contribute to that modifier
85        let apply_remap = |target: ModifierTarget,
86                           ctrl: &mut bool,
87                           alt: &mut bool,
88                           shift: &mut bool,
89                           super_key: &mut bool,
90                           is_pressed: bool| {
91            if !is_pressed {
92                return;
93            }
94            match target {
95                ModifierTarget::None => {} // Keep original behavior
96                ModifierTarget::Ctrl => *ctrl = true,
97                ModifierTarget::Alt => *alt = true,
98                ModifierTarget::Shift => *shift = true,
99                ModifierTarget::Super => *super_key = true,
100            }
101        };
102
103        // Check if any remapping is active
104        let has_remapping = remapping.left_ctrl != ModifierTarget::None
105            || remapping.right_ctrl != ModifierTarget::None
106            || remapping.left_alt != ModifierTarget::None
107            || remapping.right_alt != ModifierTarget::None
108            || remapping.left_super != ModifierTarget::None
109            || remapping.right_super != ModifierTarget::None;
110
111        if has_remapping {
112            // Get the physical key to determine which specific modifier was pressed
113            if let PhysicalKey::Code(code) = event.physical_key {
114                // Reset modifiers if we're remapping - we'll rebuild from physical keys
115                let orig_ctrl = ctrl;
116                let orig_alt = alt;
117                let _orig_shift = shift; // Shift is not remappable, but kept for consistency
118                let orig_super = super_key;
119
120                // Clear modifiers that are being remapped
121                if remapping.left_ctrl != ModifierTarget::None
122                    || remapping.right_ctrl != ModifierTarget::None
123                {
124                    ctrl = false;
125                }
126                if remapping.left_alt != ModifierTarget::None
127                    || remapping.right_alt != ModifierTarget::None
128                {
129                    alt = false;
130                }
131                if remapping.left_super != ModifierTarget::None
132                    || remapping.right_super != ModifierTarget::None
133                {
134                    super_key = false;
135                }
136
137                // Re-apply based on remapping
138                // Note: We use the original modifier state to detect which modifiers are held
139                // The physical key code tells us which specific key this event is for
140
141                // For Ctrl keys
142                if orig_ctrl {
143                    if remapping.left_ctrl != ModifierTarget::None {
144                        apply_remap(
145                            remapping.left_ctrl,
146                            &mut ctrl,
147                            &mut alt,
148                            &mut shift,
149                            &mut super_key,
150                            true,
151                        );
152                    } else if remapping.right_ctrl != ModifierTarget::None {
153                        apply_remap(
154                            remapping.right_ctrl,
155                            &mut ctrl,
156                            &mut alt,
157                            &mut shift,
158                            &mut super_key,
159                            true,
160                        );
161                    } else {
162                        ctrl = true; // No remap, keep original
163                    }
164                }
165
166                // For Alt keys
167                if orig_alt {
168                    if remapping.left_alt != ModifierTarget::None {
169                        apply_remap(
170                            remapping.left_alt,
171                            &mut ctrl,
172                            &mut alt,
173                            &mut shift,
174                            &mut super_key,
175                            true,
176                        );
177                    } else if remapping.right_alt != ModifierTarget::None {
178                        apply_remap(
179                            remapping.right_alt,
180                            &mut ctrl,
181                            &mut alt,
182                            &mut shift,
183                            &mut super_key,
184                            true,
185                        );
186                    } else {
187                        alt = true; // No remap, keep original
188                    }
189                }
190
191                // For Super keys
192                if orig_super {
193                    if remapping.left_super != ModifierTarget::None {
194                        apply_remap(
195                            remapping.left_super,
196                            &mut ctrl,
197                            &mut alt,
198                            &mut shift,
199                            &mut super_key,
200                            true,
201                        );
202                    } else if remapping.right_super != ModifierTarget::None {
203                        apply_remap(
204                            remapping.right_super,
205                            &mut ctrl,
206                            &mut alt,
207                            &mut shift,
208                            &mut super_key,
209                            true,
210                        );
211                    } else {
212                        super_key = true; // No remap, keep original
213                    }
214                }
215
216                // Handle specific physical key remaps (for when this key IS a modifier being pressed)
217                match code {
218                    KeyCode::ControlLeft if remapping.left_ctrl != ModifierTarget::None => {
219                        // This key itself is being remapped
220                    }
221                    KeyCode::ControlRight if remapping.right_ctrl != ModifierTarget::None => {}
222                    KeyCode::AltLeft if remapping.left_alt != ModifierTarget::None => {}
223                    KeyCode::AltRight if remapping.right_alt != ModifierTarget::None => {}
224                    KeyCode::SuperLeft if remapping.left_super != ModifierTarget::None => {}
225                    KeyCode::SuperRight if remapping.right_super != ModifierTarget::None => {}
226                    _ => {}
227                }
228            }
229        }
230
231        let mods = Modifiers {
232            ctrl,
233            alt,
234            shift,
235            super_key,
236            cmd_or_ctrl: false,
237        };
238
239        let key = match &event.logical_key {
240            Key::Character(c) => c
241                .chars()
242                .next()
243                .map(|ch| MatchKey::Character(ch.to_ascii_uppercase())),
244            Key::Named(named) => Some(MatchKey::Named(*named)),
245            _ => None,
246        };
247
248        let physical_key = match event.physical_key {
249            PhysicalKey::Code(code) => Some(code),
250            PhysicalKey::Unidentified(_) => None,
251        };
252
253        Self {
254            modifiers: mods,
255            key,
256            physical_key,
257        }
258    }
259
260    /// Check if this event matches the given key combo.
261    pub fn matches(&self, combo: &KeyCombo) -> bool {
262        self.matches_with_physical_preference(combo, false)
263    }
264
265    /// Check if this event matches the given key combo, with option to prefer physical keys.
266    ///
267    /// When `use_physical_keys` is true, physical key matches are attempted first for
268    /// character-based keybindings, making them work consistently across keyboard layouts.
269    pub fn matches_with_physical_preference(
270        &self,
271        combo: &KeyCombo,
272        use_physical_keys: bool,
273    ) -> bool {
274        // Check key first (quick rejection)
275        let key_matches = match (&combo.key, use_physical_keys) {
276            // Physical key binding - always match by physical key
277            (ParsedKey::Physical(combo_code), _) => self.physical_key.as_ref() == Some(combo_code),
278            // Character binding with physical key preference enabled
279            (ParsedKey::Character(combo_char), true) => {
280                // Try to match by physical key position first
281                if let Some(physical) = self.physical_key {
282                    physical_key_matches_char(physical, *combo_char)
283                } else if let Some(MatchKey::Character(event_char)) = &self.key {
284                    // Fall back to logical match if no physical key
285                    event_char.eq_ignore_ascii_case(combo_char)
286                } else {
287                    false
288                }
289            }
290            // Character binding with logical matching (default)
291            (ParsedKey::Character(combo_char), false) => {
292                if let Some(MatchKey::Character(event_char)) = &self.key {
293                    event_char.eq_ignore_ascii_case(combo_char)
294                } else {
295                    false
296                }
297            }
298            // Named key binding
299            (ParsedKey::Named(combo_named), _) => {
300                if let Some(MatchKey::Named(event_named)) = &self.key {
301                    event_named == combo_named
302                } else {
303                    false
304                }
305            }
306        };
307
308        if !key_matches {
309            return false;
310        }
311
312        // Check modifiers
313        self.modifiers_match(&combo.modifiers)
314    }
315
316    /// Check if modifiers match, handling CmdOrCtrl specially.
317    fn modifiers_match(&self, combo_mods: &Modifiers) -> bool {
318        // Handle CmdOrCtrl: on macOS it means Super, elsewhere it means Ctrl
319        let (expected_ctrl, expected_super) = if combo_mods.cmd_or_ctrl {
320            #[cfg(target_os = "macos")]
321            {
322                (combo_mods.ctrl, true) // CmdOrCtrl -> Super on macOS
323            }
324            #[cfg(not(target_os = "macos"))]
325            {
326                (true, combo_mods.super_key) // CmdOrCtrl -> Ctrl on other platforms
327            }
328        } else {
329            (combo_mods.ctrl, combo_mods.super_key)
330        };
331
332        // Check each modifier
333        self.modifiers.ctrl == expected_ctrl
334            && self.modifiers.alt == combo_mods.alt
335            && self.modifiers.shift == combo_mods.shift
336            && self.modifiers.super_key == expected_super
337    }
338}
339
340/// Check if a physical key code corresponds to a character on a QWERTY layout.
341///
342/// This maps physical key positions (scan codes) to the characters they produce
343/// on a US QWERTY keyboard, enabling language-agnostic keybindings.
344fn physical_key_matches_char(code: KeyCode, ch: char) -> bool {
345    let expected_char = match code {
346        KeyCode::KeyA => 'A',
347        KeyCode::KeyB => 'B',
348        KeyCode::KeyC => 'C',
349        KeyCode::KeyD => 'D',
350        KeyCode::KeyE => 'E',
351        KeyCode::KeyF => 'F',
352        KeyCode::KeyG => 'G',
353        KeyCode::KeyH => 'H',
354        KeyCode::KeyI => 'I',
355        KeyCode::KeyJ => 'J',
356        KeyCode::KeyK => 'K',
357        KeyCode::KeyL => 'L',
358        KeyCode::KeyM => 'M',
359        KeyCode::KeyN => 'N',
360        KeyCode::KeyO => 'O',
361        KeyCode::KeyP => 'P',
362        KeyCode::KeyQ => 'Q',
363        KeyCode::KeyR => 'R',
364        KeyCode::KeyS => 'S',
365        KeyCode::KeyT => 'T',
366        KeyCode::KeyU => 'U',
367        KeyCode::KeyV => 'V',
368        KeyCode::KeyW => 'W',
369        KeyCode::KeyX => 'X',
370        KeyCode::KeyY => 'Y',
371        KeyCode::KeyZ => 'Z',
372        KeyCode::Digit0 => '0',
373        KeyCode::Digit1 => '1',
374        KeyCode::Digit2 => '2',
375        KeyCode::Digit3 => '3',
376        KeyCode::Digit4 => '4',
377        KeyCode::Digit5 => '5',
378        KeyCode::Digit6 => '6',
379        KeyCode::Digit7 => '7',
380        KeyCode::Digit8 => '8',
381        KeyCode::Digit9 => '9',
382        KeyCode::Minus => '-',
383        KeyCode::Equal => '=',
384        KeyCode::BracketLeft => '[',
385        KeyCode::BracketRight => ']',
386        KeyCode::Backslash => '\\',
387        KeyCode::Semicolon => ';',
388        KeyCode::Quote => '\'',
389        KeyCode::Backquote => '`',
390        KeyCode::Comma => ',',
391        KeyCode::Period => '.',
392        KeyCode::Slash => '/',
393        _ => return false,
394    };
395    expected_char.eq_ignore_ascii_case(&ch)
396}
397
398// Note: Integration tests for KeybindingMatcher require constructing winit KeyEvent
399// which has private fields. The matcher is tested indirectly through the registry tests
400// and the actual runtime behavior.
401//
402// The matching logic (modifiers_match) is tested through the Modifiers struct which
403// we can construct directly.
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::parser::parse_key_combo;
409
410    /// Test that Modifiers comparison works correctly for CmdOrCtrl
411    #[test]
412    fn test_cmd_or_ctrl_modifiers() {
413        // Create a matcher with specific modifiers manually
414        let matcher_ctrl_shift = KeybindingMatcher {
415            modifiers: Modifiers {
416                ctrl: true,
417                alt: false,
418                shift: true,
419                super_key: false,
420                cmd_or_ctrl: false,
421            },
422            key: Some(MatchKey::Character('B')),
423            physical_key: Some(KeyCode::KeyB),
424        };
425
426        let matcher_super_shift = KeybindingMatcher {
427            modifiers: Modifiers {
428                ctrl: false,
429                alt: false,
430                shift: true,
431                super_key: true,
432                cmd_or_ctrl: false,
433            },
434            key: Some(MatchKey::Character('B')),
435            physical_key: Some(KeyCode::KeyB),
436        };
437
438        let combo = parse_key_combo("CmdOrCtrl+Shift+B").unwrap();
439
440        // On macOS, CmdOrCtrl means Super
441        #[cfg(target_os = "macos")]
442        {
443            assert!(matcher_super_shift.matches(&combo));
444            assert!(!matcher_ctrl_shift.matches(&combo));
445        }
446
447        // On non-macOS, CmdOrCtrl means Ctrl
448        #[cfg(not(target_os = "macos"))]
449        {
450            assert!(matcher_ctrl_shift.matches(&combo));
451            assert!(!matcher_super_shift.matches(&combo));
452        }
453    }
454
455    /// Test character key matching (case insensitive)
456    #[test]
457    fn test_character_matching() {
458        let combo = parse_key_combo("Ctrl+A").unwrap();
459
460        // Lowercase should match
461        let matcher_lower = KeybindingMatcher {
462            modifiers: Modifiers {
463                ctrl: true,
464                alt: false,
465                shift: false,
466                super_key: false,
467                cmd_or_ctrl: false,
468            },
469            key: Some(MatchKey::Character('a')),
470            physical_key: Some(KeyCode::KeyA),
471        };
472        assert!(matcher_lower.matches(&combo));
473
474        // Uppercase should match
475        let matcher_upper = KeybindingMatcher {
476            modifiers: Modifiers {
477                ctrl: true,
478                alt: false,
479                shift: false,
480                super_key: false,
481                cmd_or_ctrl: false,
482            },
483            key: Some(MatchKey::Character('A')),
484            physical_key: Some(KeyCode::KeyA),
485        };
486        assert!(matcher_upper.matches(&combo));
487
488        // Different key should not match
489        let matcher_wrong = KeybindingMatcher {
490            modifiers: Modifiers {
491                ctrl: true,
492                alt: false,
493                shift: false,
494                super_key: false,
495                cmd_or_ctrl: false,
496            },
497            key: Some(MatchKey::Character('B')),
498            physical_key: Some(KeyCode::KeyB),
499        };
500        assert!(!matcher_wrong.matches(&combo));
501    }
502
503    /// Test named key matching
504    #[test]
505    fn test_named_key_matching() {
506        let combo = parse_key_combo("F5").unwrap();
507
508        let matcher = KeybindingMatcher {
509            modifiers: Modifiers::default(),
510            key: Some(MatchKey::Named(NamedKey::F5)),
511            physical_key: Some(KeyCode::F5),
512        };
513        assert!(matcher.matches(&combo));
514
515        let matcher_wrong = KeybindingMatcher {
516            modifiers: Modifiers::default(),
517            key: Some(MatchKey::Named(NamedKey::F6)),
518            physical_key: Some(KeyCode::F6),
519        };
520        assert!(!matcher_wrong.matches(&combo));
521    }
522
523    /// Test modifier mismatch
524    #[test]
525    fn test_modifier_mismatch() {
526        let combo = parse_key_combo("Ctrl+Shift+B").unwrap();
527
528        // Missing Shift
529        let matcher = KeybindingMatcher {
530            modifiers: Modifiers {
531                ctrl: true,
532                alt: false,
533                shift: false,
534                super_key: false,
535                cmd_or_ctrl: false,
536            },
537            key: Some(MatchKey::Character('B')),
538            physical_key: Some(KeyCode::KeyB),
539        };
540        assert!(!matcher.matches(&combo));
541
542        // Extra Alt
543        let matcher = KeybindingMatcher {
544            modifiers: Modifiers {
545                ctrl: true,
546                alt: true,
547                shift: true,
548                super_key: false,
549                cmd_or_ctrl: false,
550            },
551            key: Some(MatchKey::Character('B')),
552            physical_key: Some(KeyCode::KeyB),
553        };
554        assert!(!matcher.matches(&combo));
555    }
556
557    /// Test physical key matching
558    #[test]
559    fn test_physical_key_matching() {
560        // Physical key binding should match by scan code
561        let combo = parse_key_combo("Ctrl+[KeyZ]").unwrap();
562
563        // Should match when physical key is KeyZ
564        let matcher = KeybindingMatcher {
565            modifiers: Modifiers {
566                ctrl: true,
567                alt: false,
568                shift: false,
569                super_key: false,
570                cmd_or_ctrl: false,
571            },
572            key: Some(MatchKey::Character('W')), // Different logical key (e.g., AZERTY)
573            physical_key: Some(KeyCode::KeyZ),
574        };
575        assert!(matcher.matches(&combo));
576
577        // Should not match when physical key is different
578        let matcher_wrong = KeybindingMatcher {
579            modifiers: Modifiers {
580                ctrl: true,
581                alt: false,
582                shift: false,
583                super_key: false,
584                cmd_or_ctrl: false,
585            },
586            key: Some(MatchKey::Character('Z')),
587            physical_key: Some(KeyCode::KeyW),
588        };
589        assert!(!matcher_wrong.matches(&combo));
590    }
591
592    /// Test physical key preference mode
593    #[test]
594    fn test_physical_key_preference() {
595        // Character binding with physical preference enabled
596        let combo = parse_key_combo("Ctrl+Z").unwrap();
597
598        // On AZERTY, physical KeyZ produces 'W', but with physical preference
599        // we match by position (QWERTY Z position)
600        let matcher_azerty = KeybindingMatcher {
601            modifiers: Modifiers {
602                ctrl: true,
603                alt: false,
604                shift: false,
605                super_key: false,
606                cmd_or_ctrl: false,
607            },
608            key: Some(MatchKey::Character('W')), // AZERTY produces 'W'
609            physical_key: Some(KeyCode::KeyZ),   // But physical position is KeyZ
610        };
611
612        // Without physical preference, should NOT match (W != Z)
613        assert!(!matcher_azerty.matches_with_physical_preference(&combo, false));
614
615        // With physical preference, SHOULD match (KeyZ position)
616        assert!(matcher_azerty.matches_with_physical_preference(&combo, true));
617    }
618}