Skip to main content

tui_lipan/app/input/
keymap.rs

1use crate::clipboard::{ClipboardConfig, PasteShiftInsertBehavior};
2use crate::core::event::{KeyCode, KeyEvent};
3use crate::input::{
4    ChordMatcher, ChordResult, KeyBinding, KeyBindingParseError, KeyBindings, is_none_binding,
5};
6use std::collections::{HashMap as StdHashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::sync::LazyLock;
10
11#[cfg(test)]
12use crate::core::event::KeyMods;
13
14#[cfg(not(test))]
15const KEYMAP_ENV: &str = "TUI_LIPAN_KEYMAP";
16const DEFAULT_KEYMAP_NAME: &str = "keymap.conf";
17
18/// Semantic input actions for text manipulation, navigation, and app control.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Action {
21    /// Copy selection to clipboard.
22    Copy,
23    /// Cut selection to clipboard.
24    Cut,
25    /// Paste from clipboard.
26    Paste,
27    /// Paste from primary selection (if supported).
28    PasteFromSelection,
29    /// Copy image to clipboard.
30    CopyImage,
31    /// Paste image from clipboard.
32    PasteImage,
33    /// Undo last edit.
34    Undo,
35    /// Redo last undone edit.
36    Redo,
37    /// Select all text.
38    SelectAll,
39    /// Clear all text.
40    Clear,
41
42    /// Quit the application.
43    Quit,
44
45    /// Dismiss the top-most overlay (modal/popover).
46    DismissOverlay,
47    /// Move focus to the next focusable widget.
48    FocusNext,
49    /// Move focus to the previous focusable widget.
50    FocusPrev,
51    /// Toggle DevTools visibility.
52    ToggleDevTools,
53
54    // Navigation
55    MoveLeft,
56    MoveRight,
57    MoveUp,
58    MoveDown,
59    MoveHome,
60    MoveEnd,
61    MoveWordLeft,
62    MoveWordRight,
63
64    // Selection
65    SelectLeft,
66    SelectRight,
67    SelectUp,
68    SelectDown,
69    SelectHome,
70    SelectEnd,
71    SelectWordLeft,
72    SelectWordRight,
73
74    // Editing
75    Backspace,
76    Delete,
77    DeleteWordLeft,
78    DeleteWordRight,
79    InsertNewline,
80
81    /// Default action (usually insert char).
82    InsertChar(char),
83    /// No action mapped.
84    None,
85}
86
87/// Framework-owned actions that can be configured from Rust app builders.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum FrameworkAction {
90    /// Quit the application.
91    Quit,
92    /// Dismiss the top-most overlay.
93    DismissOverlay,
94    /// Move focus to the next focusable widget.
95    FocusNext,
96    /// Move focus to the previous focusable widget.
97    FocusPrev,
98    /// Toggle DevTools visibility.
99    ToggleDevTools,
100}
101
102impl FrameworkAction {
103    fn action(self) -> Action {
104        match self {
105            Self::Quit => Action::Quit,
106            Self::DismissOverlay => Action::DismissOverlay,
107            Self::FocusNext => Action::FocusNext,
108            Self::FocusPrev => Action::FocusPrev,
109            Self::ToggleDevTools => Action::ToggleDevTools,
110        }
111    }
112}
113
114/// Enables or disables loading user keymap files from env/default paths.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub enum UserKeymapPolicy {
117    /// Load user keymap files normally.
118    #[default]
119    Enabled,
120    /// Ignore user keymap files while preserving built-in defaults.
121    Disabled,
122}
123
124#[derive(Debug, Clone)]
125enum FrameworkBindingOverride {
126    Bind(KeyBindings),
127    Unbind,
128}
129
130/// Rust-side framework keymap overrides applied after file and built-in bindings.
131#[derive(Debug, Clone, Default)]
132pub struct FrameworkKeymap {
133    overrides: StdHashMap<FrameworkAction, FrameworkBindingOverride>,
134}
135
136impl FrameworkKeymap {
137    /// Bind a framework action to one or more key bindings.
138    pub fn bind(mut self, action: FrameworkAction, bindings: KeyBindings) -> Self {
139        self.overrides
140            .insert(action, FrameworkBindingOverride::Bind(bindings));
141        self
142    }
143
144    /// Remove all key bindings for a framework action.
145    pub fn unbind(mut self, action: FrameworkAction) -> Self {
146        self.overrides
147            .insert(action, FrameworkBindingOverride::Unbind);
148        self
149    }
150}
151
152impl Action {
153    fn from_config_name(raw: &str) -> Option<Self> {
154        let mut name = raw.trim().to_ascii_lowercase();
155        if name.is_empty() {
156            return None;
157        }
158        name = name.replace('_', "-");
159        match name.as_str() {
160            "quit" | "exit" | "app-quit" | "app-exit" => Some(Self::Quit),
161            "dismiss" | "dismiss-overlay" | "close" | "cancel" | "escape" => {
162                Some(Self::DismissOverlay)
163            }
164            "focus-next" | "next-focus" | "next-widget" | "focus-forward" => Some(Self::FocusNext),
165            "focus-prev" | "focus-previous" | "prev-focus" | "previous-focus" | "prev-widget"
166            | "focus-backward" => Some(Self::FocusPrev),
167            "toggle-devtools" | "devtools-toggle" | "devtools" | "toggle-dev-tools"
168            | "toggle-debug" | "debug-toggle" => Some(Self::ToggleDevTools),
169            "copy" => Some(Self::Copy),
170            "cut" => Some(Self::Cut),
171            "paste" => Some(Self::Paste),
172            "paste-selection"
173            | "paste-from-selection"
174            | "paste-primary"
175            | "paste-primary-selection" => Some(Self::PasteFromSelection),
176            "copy-image" => Some(Self::CopyImage),
177            "paste-image" => Some(Self::PasteImage),
178            "undo" => Some(Self::Undo),
179            "redo" => Some(Self::Redo),
180            "select-all" => Some(Self::SelectAll),
181            "clear" | "clear-text" | "clear-input" => Some(Self::Clear),
182            "move-left" => Some(Self::MoveLeft),
183            "move-right" => Some(Self::MoveRight),
184            "move-up" => Some(Self::MoveUp),
185            "move-down" => Some(Self::MoveDown),
186            "move-home" => Some(Self::MoveHome),
187            "move-end" => Some(Self::MoveEnd),
188            "move-word-left" => Some(Self::MoveWordLeft),
189            "move-word-right" => Some(Self::MoveWordRight),
190            "select-left" => Some(Self::SelectLeft),
191            "select-right" => Some(Self::SelectRight),
192            "select-up" => Some(Self::SelectUp),
193            "select-down" => Some(Self::SelectDown),
194            "select-home" => Some(Self::SelectHome),
195            "select-end" => Some(Self::SelectEnd),
196            "select-word-left" => Some(Self::SelectWordLeft),
197            "select-word-right" => Some(Self::SelectWordRight),
198            "backspace" => Some(Self::Backspace),
199            "delete" => Some(Self::Delete),
200            "delete-word-left" => Some(Self::DeleteWordLeft),
201            "delete-word-right" => Some(Self::DeleteWordRight),
202            "insert-newline" | "newline" | "enter" => Some(Self::InsertNewline),
203            "none" | "unbind" | "disabled" => Some(Self::None),
204            _ => None,
205        }
206    }
207
208    pub(crate) fn is_clipboard(self) -> bool {
209        matches!(
210            self,
211            Self::Copy
212                | Self::Cut
213                | Self::Paste
214                | Self::PasteFromSelection
215                | Self::CopyImage
216                | Self::PasteImage
217        )
218    }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub(crate) enum BindingMode {
223    Performable,
224    Always,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub(crate) struct BindingMatch {
229    pub action: Action,
230    pub mode: BindingMode,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub(crate) struct KeymapRuntimeMatch {
235    pub action: Action,
236    pub mode: BindingMode,
237    pub is_chord: bool,
238}
239
240impl KeymapRuntimeMatch {
241    pub(crate) fn binding_match(self) -> BindingMatch {
242        BindingMatch {
243            action: self.action,
244            mode: self.mode,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub(crate) enum KeymapRuntimeResult {
251    None,
252    Pending,
253    Matched(KeymapRuntimeMatch),
254}
255
256#[derive(Debug, Clone)]
257pub(crate) struct Binding {
258    combination: KeyBinding,
259    action: Action,
260    mode: BindingMode,
261}
262
263#[derive(Debug, Clone)]
264pub struct Keymap {
265    bindings: Vec<Binding>,
266    /// Index from combination to the index of the first matching binding.
267    index: StdHashMap<KeyBinding, Vec<usize>>,
268}
269
270pub(crate) struct KeymapMatcher {
271    matcher: ChordMatcher<KeymapRuntimeMatch>,
272}
273
274pub(crate) type KeymapRuntime = KeymapMatcher;
275
276impl KeymapMatcher {
277    pub(crate) fn new(keymap: &Keymap) -> Self {
278        let entries = keymap
279            .bindings
280            .iter()
281            .map(|binding| {
282                (
283                    binding.combination.clone(),
284                    KeymapRuntimeMatch {
285                        action: binding.action,
286                        mode: binding.mode,
287                        is_chord: binding.combination.is_chord(),
288                    },
289                )
290            })
291            .collect();
292        Self {
293            matcher: ChordMatcher::new(entries),
294        }
295    }
296
297    pub(crate) fn feed(&mut self, key: KeyEvent) -> KeymapRuntimeResult {
298        match self.matcher.feed(&key) {
299            ChordResult::None => KeymapRuntimeResult::None,
300            ChordResult::Pending => KeymapRuntimeResult::Pending,
301            ChordResult::Matched(binding) => KeymapRuntimeResult::Matched(*binding),
302        }
303    }
304
305    pub(crate) fn reset(&mut self) {
306        self.matcher.reset();
307    }
308
309    #[cfg(test)]
310    pub(crate) fn is_pending(&self) -> bool {
311        self.matcher.is_pending()
312    }
313}
314
315#[derive(Debug, Clone)]
316pub(crate) struct KeymapConfig {
317    pub enable_performable_ctrl_c_copy: bool,
318    pub paste_shift_insert_behavior: PasteShiftInsertBehavior,
319    pub keymap_path: Option<PathBuf>,
320    pub user_keymap_policy: UserKeymapPolicy,
321    pub framework_keymap: FrameworkKeymap,
322}
323
324impl KeymapConfig {
325    pub fn from_clipboard_config(config: &ClipboardConfig) -> Self {
326        Self {
327            enable_performable_ctrl_c_copy: config.enable_performable_ctrl_c_copy,
328            paste_shift_insert_behavior: config.paste_shift_insert_behavior,
329            keymap_path: None,
330            user_keymap_policy: UserKeymapPolicy::Enabled,
331            framework_keymap: FrameworkKeymap::default(),
332        }
333    }
334
335    pub fn keymap_path(mut self, path: impl Into<PathBuf>) -> Self {
336        self.keymap_path = Some(path.into());
337        self
338    }
339
340    pub fn user_keymap_policy(mut self, policy: UserKeymapPolicy) -> Self {
341        self.user_keymap_policy = policy;
342        self
343    }
344
345    pub fn framework_keymap(mut self, keymap: FrameworkKeymap) -> Self {
346        self.framework_keymap = keymap;
347        self
348    }
349}
350
351fn parse_binding(raw: &str) -> Result<KeyBinding, KeyBindingParseError> {
352    KeyBinding::from_str(raw)
353}
354
355fn binding_mode_for(action: Action, _combination: &KeyBinding) -> BindingMode {
356    if action.is_clipboard() {
357        BindingMode::Performable
358    } else {
359        BindingMode::Always
360    }
361}
362
363fn default_keymap_path() -> Option<PathBuf> {
364    if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
365        return Some(
366            PathBuf::from(dir)
367                .join("tui-lipan")
368                .join(DEFAULT_KEYMAP_NAME),
369        );
370    }
371    if let Ok(dir) = std::env::var("APPDATA") {
372        return Some(
373            PathBuf::from(dir)
374                .join("tui-lipan")
375                .join(DEFAULT_KEYMAP_NAME),
376        );
377    }
378    if let Ok(home) = std::env::var("HOME") {
379        return Some(
380            PathBuf::from(home)
381                .join(".config")
382                .join("tui-lipan")
383                .join(DEFAULT_KEYMAP_NAME),
384        );
385    }
386    None
387}
388
389#[cfg(test)]
390static TEST_KEYMAP_ENV: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
391
392#[cfg(test)]
393fn set_test_keymap_env(path: impl Into<PathBuf>) {
394    *TEST_KEYMAP_ENV.lock().expect("test env lock") = Some(path.into());
395}
396
397#[cfg(test)]
398fn remove_test_keymap_env() {
399    *TEST_KEYMAP_ENV.lock().expect("test env lock") = None;
400}
401
402#[cfg(test)]
403fn env_keymap_path() -> Option<PathBuf> {
404    TEST_KEYMAP_ENV.lock().expect("test env lock").clone()
405}
406
407#[cfg(not(test))]
408fn env_keymap_path() -> Option<PathBuf> {
409    if let Ok(raw) = std::env::var(KEYMAP_ENV) {
410        let raw = raw.trim();
411        if !raw.is_empty() {
412            return Some(PathBuf::from(raw));
413        }
414    }
415    None
416}
417
418fn resolve_keymap_path(config: &KeymapConfig) -> Option<(PathBuf, bool)> {
419    if let Some(path) = config.keymap_path.clone() {
420        return Some((path, true));
421    }
422
423    if let Some(path) = env_keymap_path() {
424        return Some((path, true));
425    }
426    let path = default_keymap_path()?;
427    if path.is_file() {
428        Some((path, false))
429    } else {
430        None
431    }
432}
433
434#[cfg(test)]
435fn parse_keymap_config(path: &Path, contents: &str) -> Vec<Binding> {
436    parse_keymap_file(path, contents).bindings
437}
438
439#[derive(Debug, Default)]
440struct ParsedKeymapConfig {
441    bindings: Vec<Binding>,
442    overridden_actions: HashSet<Action>,
443}
444
445fn parse_keymap_file(path: &Path, contents: &str) -> ParsedKeymapConfig {
446    let mut bindings = Vec::new();
447    let mut overridden_actions = HashSet::new();
448
449    for (line_idx, raw_line) in contents.lines().enumerate() {
450        let line = raw_line.split('#').next().unwrap_or("").trim();
451        if line.is_empty() {
452            continue;
453        }
454
455        let Some((action_raw, keys_raw)) = line.split_once(['=', ':']) else {
456            crate::debug::internal_log!(
457                "[tui-lipan] Invalid keymap entry at {}:{} (expected 'action = keys')",
458                path.display(),
459                line_idx + 1
460            );
461            continue;
462        };
463
464        let action_name = action_raw.trim();
465        let Some(action) = Action::from_config_name(action_name) else {
466            crate::debug::internal_log!(
467                "[tui-lipan] Unknown keymap action '{}' at {}:{}",
468                action_name,
469                path.display(),
470                line_idx + 1
471            );
472            continue;
473        };
474
475        overridden_actions.insert(action);
476
477        for key in keys_raw.split(',') {
478            let key = key.trim();
479            if key.is_empty() {
480                continue;
481            }
482            if is_none_binding(key) {
483                continue;
484            }
485            match parse_binding(key) {
486                Ok(comb) => {
487                    let mode = binding_mode_for(action, &comb);
488                    bindings.push(Binding {
489                        combination: comb,
490                        action,
491                        mode,
492                    });
493                }
494                Err(err) => {
495                    crate::debug::internal_log!(
496                        "[tui-lipan] Invalid key binding '{}' at {}:{}: {}",
497                        key,
498                        path.display(),
499                        line_idx + 1,
500                        err
501                    );
502                }
503            }
504        }
505    }
506
507    ParsedKeymapConfig {
508        bindings,
509        overridden_actions,
510    }
511}
512
513fn load_user_bindings(config: &KeymapConfig) -> ParsedKeymapConfig {
514    if config.user_keymap_policy == UserKeymapPolicy::Disabled {
515        return ParsedKeymapConfig::default();
516    }
517
518    let Some((path, explicit)) = resolve_keymap_path(config) else {
519        return ParsedKeymapConfig::default();
520    };
521
522    let contents = match std::fs::read_to_string(&path) {
523        Ok(contents) => contents,
524        Err(err) => {
525            if explicit {
526                crate::debug::internal_log!(
527                    "[tui-lipan] Failed to read keymap file {}: {}",
528                    path.display(),
529                    err
530                );
531            }
532            return ParsedKeymapConfig::default();
533        }
534    };
535
536    parse_keymap_file(&path, &contents)
537}
538
539fn apply_framework_keymap_overrides(bindings: &mut Vec<Binding>, keymap: &FrameworkKeymap) {
540    for (framework_action, override_) in &keymap.overrides {
541        let action = framework_action.action();
542        bindings.retain(|binding| binding.action != action);
543        if let FrameworkBindingOverride::Bind(key_bindings) = override_ {
544            bindings.extend(key_bindings.iter().cloned().map(|combination| Binding {
545                mode: binding_mode_for(action, &combination),
546                combination,
547                action,
548            }));
549        }
550    }
551}
552
553fn default_bindings(config: &KeymapConfig) -> Vec<Binding> {
554    let mut bindings = Vec::new();
555
556    // Helper to add bindings. Warning: ORDER MATTERS. Specific bindings first.
557    let mut bind = |key: &str, action: Action| match parse_binding(key) {
558        Ok(comb) => {
559            let mode = binding_mode_for(action, &comb);
560            bindings.push(Binding {
561                combination: comb,
562                action,
563                mode,
564            });
565        }
566        Err(e) => crate::debug::internal_log!("[tui-lipan] Invalid key binding '{}': {}", key, e),
567    };
568
569    // --- Clipboard ---
570    if config.enable_performable_ctrl_c_copy {
571        bind("ctrl-c", Action::Copy);
572    }
573    bind("ctrl-shift-c", Action::Copy);
574    bind("ctrl-insert", Action::Copy); // Legacy CUA
575    bind("super-c", Action::Copy);
576    bind("super-insert", Action::Copy); // Common Linux/VM binding
577
578    bind("ctrl-x", Action::Cut);
579    bind("super-x", Action::Cut);
580    bind("shift-delete", Action::Cut); // Legacy CUA
581
582    bind("ctrl-v", Action::Paste);
583    match config.paste_shift_insert_behavior {
584        PasteShiftInsertBehavior::Clipboard => bind("shift-insert", Action::Paste),
585        PasteShiftInsertBehavior::PrimarySelection => {
586            bind("shift-insert", Action::PasteFromSelection)
587        }
588    }
589    bind("ctrl-shift-v", Action::Paste);
590    bind("super-v", Action::Paste);
591    bind("super-shift-v", Action::Paste); // Explicit SUPER+SHIFT+V binding often sent by terminals for "Paste"
592
593    // --- Image clipboard ---
594    // Note: no default key for PasteImage - smart paste via Ctrl+V handles it automatically
595    // when the focused widget has `on_image_paste` set. Users can bind `paste-image` manually.
596    bind("ctrl-shift-y", Action::CopyImage);
597
598    // --- Undo/Redo ---
599    bind("ctrl-z", Action::Undo);
600    bind("ctrl-shift-z", Action::Redo); // Common variant
601    bind("ctrl-y", Action::Redo); // Windows variant
602
603    // --- Selection ---
604    bind("ctrl-a", Action::SelectAll);
605
606    // Word selection
607    bind("ctrl-shift-left", Action::SelectWordLeft);
608    bind("alt-shift-left", Action::SelectWordLeft); // Mac-style
609    bind("ctrl-shift-right", Action::SelectWordRight);
610    bind("alt-shift-right", Action::SelectWordRight);
611
612    // Char selection
613    bind("shift-left", Action::SelectLeft);
614    bind("shift-right", Action::SelectRight);
615    bind("shift-up", Action::SelectUp);
616    bind("shift-down", Action::SelectDown);
617    bind("shift-home", Action::SelectHome);
618    bind("shift-end", Action::SelectEnd);
619
620    // --- Navigation ---
621    // Word movement
622    bind("ctrl-left", Action::MoveWordLeft);
623    bind("alt-left", Action::MoveWordLeft);
624    bind("ctrl-right", Action::MoveWordRight);
625    bind("alt-right", Action::MoveWordRight);
626
627    // Basic movement
628    bind("left", Action::MoveLeft);
629    bind("right", Action::MoveRight);
630    bind("up", Action::MoveUp);
631    bind("down", Action::MoveDown);
632    bind("home", Action::MoveHome);
633    bind("end", Action::MoveEnd);
634
635    // --- Deletion ---
636    bind("ctrl-backspace", Action::DeleteWordLeft);
637    bind("alt-backspace", Action::DeleteWordLeft);
638    bind("ctrl-delete", Action::DeleteWordRight);
639    bind("alt-delete", Action::DeleteWordRight);
640    bind("backspace", Action::Backspace);
641    bind("delete", Action::Delete);
642
643    bind("enter", Action::InsertNewline);
644
645    // --- App ---
646    bind("ctrl-q", Action::Quit);
647
648    // --- Overlays ---
649    bind("esc", Action::DismissOverlay);
650
651    // --- Focus traversal ---
652    bind("tab", Action::FocusNext);
653    bind("shift-tab", Action::FocusPrev);
654
655    // --- DevTools ---
656    bind("f12", Action::ToggleDevTools);
657
658    bindings
659}
660
661fn build_binding_index(bindings: &[Binding]) -> StdHashMap<KeyBinding, Vec<usize>> {
662    let mut index: StdHashMap<KeyBinding, Vec<usize>> = StdHashMap::new();
663    for (i, binding) in bindings.iter().enumerate() {
664        index
665            .entry(binding.combination.clone())
666            .or_default()
667            .push(i);
668    }
669    index
670}
671
672impl Keymap {
673    pub(crate) fn new(config: KeymapConfig) -> Self {
674        let user_keymap = load_user_bindings(&config);
675        let mut bindings = Vec::new();
676        bindings.extend(user_keymap.bindings.iter().cloned());
677
678        let defaults = default_bindings(&config);
679        for binding in defaults {
680            if user_keymap.overridden_actions.contains(&binding.action)
681                || user_keymap
682                    .bindings
683                    .iter()
684                    .any(|user| user.combination == binding.combination)
685            {
686                continue;
687            }
688            bindings.push(binding);
689        }
690        apply_framework_keymap_overrides(&mut bindings, &config.framework_keymap);
691        let index = build_binding_index(&bindings);
692        Self { bindings, index }
693    }
694
695    pub(crate) fn resolve_action(&self, key: KeyEvent) -> Action {
696        let normalized_key = crate::input::normalize_ctrl_char(key);
697        let event_binding = KeyBinding::from_key_event(key);
698
699        if let Some(indices) = self.index.get(&event_binding)
700            && let Some(&idx) = indices.first()
701        {
702            return self.bindings[idx].action;
703        }
704
705        match normalized_key.code {
706            KeyCode::Char(c) => {
707                if normalized_key.mods.ctrl || normalized_key.mods.super_key {
708                    Action::None
709                } else {
710                    Action::InsertChar(c)
711                }
712            }
713            _ => Action::None,
714        }
715    }
716
717    pub(crate) fn matches(&self, key: KeyEvent) -> Vec<BindingMatch> {
718        let event_comb = KeyBinding::from_key_event(key);
719
720        match self.index.get(&event_comb) {
721            Some(indices) => {
722                // Most keypresses match 1-2 bindings; pre-allocate to avoid
723                // incremental growth from collect()'s default empty Vec.
724                let mut result = Vec::with_capacity(indices.len().min(4));
725                for &idx in indices {
726                    let binding = &self.bindings[idx];
727                    result.push(BindingMatch {
728                        action: binding.action,
729                        mode: binding.mode,
730                    });
731                }
732                result
733            }
734            None => Vec::new(),
735        }
736    }
737
738    pub fn binding_for_action(&self, action: Action) -> Option<&KeyBinding> {
739        self.bindings
740            .iter()
741            .find(|binding| binding.action == action)
742            .map(|binding| &binding.combination)
743    }
744}
745impl Default for Keymap {
746    fn default() -> Self {
747        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default());
748        Self::new(config)
749    }
750}
751
752static DEFAULT_KEYMAP: LazyLock<Keymap> = LazyLock::new(Keymap::default);
753
754pub(crate) fn default_keymap() -> &'static Keymap {
755    &DEFAULT_KEYMAP
756}
757
758#[cfg(test)]
759pub(crate) fn binding_for_test(key: &str, action: Action, mode: BindingMode) -> Binding {
760    let combination = parse_binding(key).expect("test binding parses");
761    Binding {
762        combination,
763        action,
764        mode,
765    }
766}
767
768#[cfg(test)]
769pub(crate) fn keymap_for_test(bindings: Vec<Binding>) -> Keymap {
770    let index = build_binding_index(&bindings);
771    Keymap { bindings, index }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use std::fs;
778    use std::time::{SystemTime, UNIX_EPOCH};
779
780    fn plain_key(c: char) -> KeyEvent {
781        KeyEvent {
782            code: KeyCode::Char(c),
783            mods: KeyMods::default(),
784        }
785    }
786
787    fn ctrl_key(c: char) -> KeyEvent {
788        KeyEvent {
789            code: KeyCode::Char(c),
790            mods: KeyMods {
791                ctrl: true,
792                ..KeyMods::default()
793            },
794        }
795    }
796
797    #[test]
798    fn super_alias_parses() {
799        let super_comb = parse_binding("super-c").expect("super alias parses");
800        let cmd_comb = parse_binding("cmd-c").expect("cmd parses");
801        assert_eq!(super_comb, cmd_comb);
802    }
803
804    #[test]
805    fn normalizes_raw_ctrl_char() {
806        let key = KeyEvent {
807            code: KeyCode::Char('\x03'),
808            mods: KeyMods::default(),
809        };
810        let normalized = crate::input::normalize_ctrl_char(key);
811        assert!(normalized.mods.ctrl);
812        assert_eq!(normalized.code, KeyCode::Char('c'));
813    }
814
815    #[test]
816    fn resolve_complex_combinations() {
817        // Test binding a complex key and resolving it
818        // We'll manually parse a config to test the resolution logic without relying on default bindings
819        // or the global keymap.
820        // However, we can test `parse_keymap_config` and `to_crokey_event`.
821
822        let _config = "
823            custom-action = super-alt-x
824            move-up = ctrl-shift-up
825        ";
826        // We need a dummy path
827        let path = PathBuf::from("test_config.conf");
828        // Note: `custom-action` is NOT a valid Action variant, so it should be skipped/logged error.
829        // Let's use valid actions.
830        let config_valid = "
831            copy = super-alt-c
832            move-up = ctrl-shift-up
833        ";
834
835        let bindings = parse_keymap_config(&path, config_valid);
836
837        // verify mappings
838        let comb1 = bindings
839            .iter()
840            .find(|binding| binding.action == Action::Copy)
841            .expect("Copy binding found")
842            .combination
843            .clone();
844        let comb2 = bindings
845            .iter()
846            .find(|binding| binding.action == Action::MoveUp)
847            .expect("MoveUp binding found")
848            .combination
849            .clone();
850
851        // Verify matches using normalized binding comparison
852        let expected_copy = parse_binding("cmd-alt-c").unwrap();
853        let expected_move = parse_binding("ctrl-shift-up").unwrap();
854
855        assert_eq!(comb1, expected_copy);
856        assert_eq!(comb2, expected_move);
857    }
858
859    #[test]
860    fn explicit_keymap_path_takes_priority() {
861        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
862            .keymap_path("/tmp/project-keymap.conf");
863        let resolved = resolve_keymap_path(&config).expect("explicit path should resolve");
864
865        assert_eq!(resolved.0, PathBuf::from("/tmp/project-keymap.conf"));
866        assert!(resolved.1);
867    }
868
869    fn write_temp_keymap(contents: &str) -> PathBuf {
870        let unique = SystemTime::now()
871            .duration_since(UNIX_EPOCH)
872            .expect("time should move forward")
873            .as_nanos();
874        let path = std::env::temp_dir().join(format!("tui-lipan-keymap-{unique}.conf"));
875        fs::write(&path, contents).expect("write test keymap");
876        path
877    }
878
879    #[test]
880    fn rust_framework_unbind_beats_explicit_keymap_file() {
881        let path = write_temp_keymap("quit = ctrl-q\n");
882        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
883            .keymap_path(&path)
884            .framework_keymap(FrameworkKeymap::default().unbind(FrameworkAction::Quit));
885        let keymap = Keymap::new(config);
886        let _ = fs::remove_file(&path);
887        assert!(keymap.matches(ctrl_key('q')).is_empty());
888    }
889
890    #[test]
891    fn app_keymap_path_beats_env_keymap() {
892        let env_path = write_temp_keymap("quit = ctrl-x q\n");
893        let app_path = write_temp_keymap("quit = ctrl-y q\n");
894        set_test_keymap_env(&env_path);
895        let config =
896            KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&app_path);
897        let keymap = Keymap::new(config);
898        remove_test_keymap_env();
899        let _ = fs::remove_file(&env_path);
900        let _ = fs::remove_file(&app_path);
901        assert!(
902            keymap
903                .binding_for_action(Action::Quit)
904                .is_some_and(|b| b.canonical_lowercase() == "ctrl+y q")
905        );
906    }
907
908    #[test]
909    fn rust_framework_rebind_beats_file_none() {
910        let path = write_temp_keymap("quit = none\n");
911        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
912            .keymap_path(&path)
913            .framework_keymap(FrameworkKeymap::default().bind(
914                FrameworkAction::Quit,
915                KeyBindings::from_str("ctrl-y q").unwrap(),
916            ));
917        let keymap = Keymap::new(config);
918        let _ = fs::remove_file(&path);
919        assert!(
920            keymap
921                .binding_for_action(Action::Quit)
922                .is_some_and(|b| b.canonical_lowercase() == "ctrl+y q")
923        );
924    }
925
926    #[test]
927    fn user_keymap_policy_disabled_ignores_env_and_default_files() {
928        let path = write_temp_keymap("quit = ctrl-x q\n");
929        set_test_keymap_env(&path);
930        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
931            .user_keymap_policy(UserKeymapPolicy::Disabled);
932        let keymap = Keymap::new(config);
933        remove_test_keymap_env();
934        let _ = fs::remove_file(&path);
935        assert!(keymap.binding_for_action(Action::Quit).is_some());
936        assert!(
937            keymap
938                .matches(ctrl_key('q'))
939                .iter()
940                .any(|b| b.action == Action::Quit)
941        );
942        assert!(
943            keymap
944                .matches(ctrl_key('x'))
945                .iter()
946                .all(|binding| binding.action != Action::Quit)
947        );
948    }
949
950    #[test]
951    fn user_keymap_policy_disabled_does_not_disable_builtins() {
952        let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
953            .user_keymap_policy(UserKeymapPolicy::Disabled);
954        let keymap = Keymap::new(config);
955        assert!(
956            keymap
957                .matches(ctrl_key('q'))
958                .iter()
959                .any(|b| b.action == Action::Quit)
960        );
961        assert!(
962            keymap
963                .matches(ctrl_key('c'))
964                .iter()
965                .any(|b| b.action == Action::Copy)
966        );
967    }
968
969    #[test]
970    fn clipboard_bindings_are_performable() {
971        let copy = parse_binding("super-c").expect("copy binding parses");
972        let cut = parse_binding("shift-delete").expect("cut binding parses");
973        let paste = parse_binding("super-v").expect("paste binding parses");
974        let quit = parse_binding("ctrl-q").expect("quit binding parses");
975
976        assert_eq!(
977            binding_mode_for(Action::Copy, &copy),
978            BindingMode::Performable
979        );
980        assert_eq!(
981            binding_mode_for(Action::Cut, &cut),
982            BindingMode::Performable
983        );
984        assert_eq!(
985            binding_mode_for(Action::Paste, &paste),
986            BindingMode::Performable
987        );
988        assert_eq!(binding_mode_for(Action::Quit, &quit), BindingMode::Always);
989    }
990
991    #[test]
992    fn resolve_ctrl_v() {
993        // Test that Ctrl+V resolves to Paste
994        let keymap = default_keymap();
995
996        // Case 1: Raw control char (legacy terminal)
997        let key_raw = KeyEvent {
998            code: KeyCode::Char('\x16'), // Ctrl+V is ASCII 22
999            mods: KeyMods::default(),
1000        };
1001        assert_eq!(
1002            keymap.resolve_action(key_raw),
1003            Action::Paste,
1004            "Raw Ctrl+V should resolve to Paste"
1005        );
1006
1007        // Case 2: Discrete event with lowercase 'v' (enhanced terminal)
1008        let key_lower = KeyEvent {
1009            code: KeyCode::Char('v'),
1010            mods: KeyMods {
1011                ctrl: true,
1012                ..KeyMods::default()
1013            },
1014        };
1015        assert_eq!(
1016            keymap.resolve_action(key_lower),
1017            Action::Paste,
1018            "Ctrl+v (lowercase) should resolve to Paste"
1019        );
1020
1021        // Case 3: Discrete event with uppercase 'V' (some terminals report this)
1022        let key_upper = KeyEvent {
1023            code: KeyCode::Char('V'),
1024            mods: KeyMods {
1025                ctrl: true,
1026                ..KeyMods::default()
1027            },
1028        };
1029        assert_eq!(
1030            keymap.resolve_action(key_upper),
1031            Action::Paste,
1032            "Ctrl+V (uppercase) should resolve to Paste"
1033        );
1034    }
1035
1036    #[test]
1037    fn default_keymap_binds_tab_focus_actions() {
1038        let keymap = default_keymap();
1039
1040        assert_eq!(
1041            keymap.resolve_action(KeyEvent {
1042                code: KeyCode::Tab,
1043                mods: KeyMods::default(),
1044            }),
1045            Action::FocusNext
1046        );
1047        assert_eq!(
1048            keymap.resolve_action(KeyEvent {
1049                code: KeyCode::BackTab,
1050                mods: KeyMods::default(),
1051            }),
1052            Action::FocusPrev
1053        );
1054    }
1055
1056    #[test]
1057    fn default_keymap_binds_f12_to_toggle_devtools() {
1058        let keymap = default_keymap();
1059
1060        assert_eq!(
1061            keymap.resolve_action(KeyEvent {
1062                code: KeyCode::F(12),
1063                mods: KeyMods::default(),
1064            }),
1065            Action::ToggleDevTools
1066        );
1067    }
1068
1069    #[test]
1070    fn parses_toggle_devtools_aliases() {
1071        assert_eq!(
1072            Action::from_config_name("toggle-devtools"),
1073            Some(Action::ToggleDevTools)
1074        );
1075        assert_eq!(
1076            Action::from_config_name("devtools-toggle"),
1077            Some(Action::ToggleDevTools)
1078        );
1079        assert_eq!(
1080            Action::from_config_name("toggle_debug"),
1081            Some(Action::ToggleDevTools)
1082        );
1083    }
1084
1085    #[test]
1086    fn parses_clear_aliases() {
1087        assert_eq!(Action::from_config_name("clear"), Some(Action::Clear));
1088        assert_eq!(Action::from_config_name("clear-text"), Some(Action::Clear));
1089        assert_eq!(Action::from_config_name("clear_input"), Some(Action::Clear));
1090    }
1091
1092    #[test]
1093    fn default_keymap_does_not_bind_ctrl_c_to_quit() {
1094        let keymap = default_keymap();
1095        let matches = keymap.matches(KeyEvent {
1096            code: KeyCode::Char('c'),
1097            mods: KeyMods {
1098                ctrl: true,
1099                ..KeyMods::default()
1100            },
1101        });
1102
1103        assert!(matches.iter().any(|binding| binding.action == Action::Copy));
1104        assert!(!matches.iter().any(|binding| binding.action == Action::Quit));
1105    }
1106
1107    #[test]
1108    fn user_keymap_can_remap_and_disable_focus_actions() {
1109        let unique = SystemTime::now()
1110            .duration_since(UNIX_EPOCH)
1111            .expect("time should move forward")
1112            .as_nanos();
1113        let path = std::env::temp_dir().join(format!("tui-lipan-keymap-{unique}.conf"));
1114        fs::write(&path, "focus-next = ctrl-j\nfocus-prev = none\n").expect("write test keymap");
1115
1116        let keymap = Keymap::new(
1117            KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&path),
1118        );
1119
1120        let _ = fs::remove_file(&path);
1121
1122        assert_eq!(
1123            keymap.resolve_action(KeyEvent {
1124                code: KeyCode::Tab,
1125                mods: KeyMods::default(),
1126            }),
1127            Action::None,
1128        );
1129        assert_eq!(
1130            keymap.resolve_action(KeyEvent {
1131                code: KeyCode::BackTab,
1132                mods: KeyMods::default(),
1133            }),
1134            Action::None,
1135        );
1136        assert_eq!(
1137            keymap.resolve_action(KeyEvent {
1138                code: KeyCode::Char('j'),
1139                mods: KeyMods {
1140                    ctrl: true,
1141                    ..KeyMods::default()
1142                },
1143            }),
1144            Action::FocusNext,
1145        );
1146    }
1147
1148    #[test]
1149    fn user_clear_binding_overrides_default_copy_combo() {
1150        let unique = SystemTime::now()
1151            .duration_since(UNIX_EPOCH)
1152            .expect("time should move forward")
1153            .as_nanos();
1154        let path = std::env::temp_dir().join(format!("tui-lipan-keymap-clear-{unique}.conf"));
1155        fs::write(&path, "clear = ctrl-c\n").expect("write test keymap");
1156
1157        let keymap = Keymap::new(
1158            KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&path),
1159        );
1160
1161        let _ = fs::remove_file(&path);
1162
1163        let ctrl_c = KeyEvent {
1164            code: KeyCode::Char('c'),
1165            mods: KeyMods {
1166                ctrl: true,
1167                ..KeyMods::default()
1168            },
1169        };
1170        assert_eq!(keymap.resolve_action(ctrl_c), Action::Clear);
1171        let matches = keymap.matches(ctrl_c);
1172        assert!(
1173            matches
1174                .iter()
1175                .any(|binding| binding.action == Action::Clear)
1176        );
1177        assert!(!matches.iter().any(|binding| binding.action == Action::Copy));
1178    }
1179
1180    #[test]
1181    fn keymap_runtime_matches_chorded_action() {
1182        let keymap = keymap_for_test(vec![binding_for_test(
1183            "ctrl-x q",
1184            Action::Quit,
1185            BindingMode::Always,
1186        )]);
1187        let mut runtime = KeymapRuntime::new(&keymap);
1188
1189        assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1190        assert!(runtime.is_pending());
1191        assert_eq!(
1192            runtime.feed(plain_key('q')),
1193            KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1194                action: Action::Quit,
1195                mode: BindingMode::Always,
1196                is_chord: true,
1197            })
1198        );
1199        assert!(!runtime.is_pending());
1200    }
1201
1202    #[test]
1203    fn keymap_runtime_mismatch_resets_and_allows_fresh_match() {
1204        let keymap = keymap_for_test(vec![
1205            binding_for_test("ctrl-x q", Action::Quit, BindingMode::Always),
1206            binding_for_test("ctrl-g", Action::DismissOverlay, BindingMode::Always),
1207        ]);
1208        let mut runtime = KeymapRuntime::new(&keymap);
1209
1210        assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1211        assert_eq!(
1212            runtime.feed(ctrl_key('g')),
1213            KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1214                action: Action::DismissOverlay,
1215                mode: BindingMode::Always,
1216                is_chord: false,
1217            })
1218        );
1219        assert!(!runtime.is_pending());
1220    }
1221
1222    #[test]
1223    fn keymap_runtime_mismatch_resets_to_none() {
1224        let keymap = keymap_for_test(vec![binding_for_test(
1225            "ctrl-x q",
1226            Action::Quit,
1227            BindingMode::Always,
1228        )]);
1229        let mut runtime = KeymapRuntime::new(&keymap);
1230
1231        assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1232        assert_eq!(runtime.feed(plain_key('z')), KeymapRuntimeResult::None);
1233        assert!(!runtime.is_pending());
1234    }
1235
1236    #[test]
1237    fn keymap_runtime_preserves_single_key_matches() {
1238        let keymap = keymap_for_test(vec![binding_for_test(
1239            "ctrl-q",
1240            Action::Quit,
1241            BindingMode::Always,
1242        )]);
1243        let mut runtime = KeymapRuntime::new(&keymap);
1244
1245        assert_eq!(keymap.resolve_action(ctrl_key('q')), Action::Quit);
1246        assert_eq!(
1247            runtime.feed(ctrl_key('q')),
1248            KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1249                action: Action::Quit,
1250                mode: BindingMode::Always,
1251                is_chord: false,
1252            })
1253        );
1254    }
1255}