Skip to main content

fresh/app/keybinding_editor/
editor.rs

1//! KeybindingEditor - the main editor state and logic.
2
3use super::helpers::{format_chord_keys, key_code_to_config_name, modifiers_to_config_names};
4use super::types::*;
5use crate::config::{Config, Keybinding};
6use crate::input::command_registry::CommandRegistry;
7use crate::input::keybindings::{format_keybinding, Action, KeyContext, KeybindingResolver};
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9use rust_i18n::t;
10use std::collections::{HashMap, HashSet};
11
12/// The main keybinding editor state
13#[derive(Debug)]
14pub struct KeybindingEditor {
15    /// All resolved bindings
16    pub bindings: Vec<ResolvedBinding>,
17    /// Indices into `bindings` after filtering/searching
18    pub filtered_indices: Vec<usize>,
19    /// Currently selected index (within filtered list)
20    pub selected: usize,
21    /// Scroll state (offset, viewport, content_height) — shared with render
22    pub scroll: crate::view::ui::ScrollState,
23
24    /// Whether search is active (search bar visible)
25    pub search_active: bool,
26    /// Whether search input is focused (accepting keystrokes)
27    pub search_focused: bool,
28    /// Search query string
29    pub search_query: String,
30    /// Search mode (text or record key)
31    pub search_mode: SearchMode,
32    /// Recorded search key display (when in RecordKey mode)
33    pub search_key_display: String,
34    /// Recorded search key code (when in RecordKey mode)
35    pub search_key_code: Option<KeyCode>,
36    /// Recorded search modifiers (when in RecordKey mode)
37    pub search_modifiers: KeyModifiers,
38
39    /// Context filter
40    pub context_filter: ContextFilter,
41    /// Source filter
42    pub source_filter: SourceFilter,
43
44    /// Edit/add binding dialog state (None = not open)
45    pub edit_dialog: Option<EditBindingState>,
46
47    /// Whether help overlay is showing
48    pub showing_help: bool,
49
50    /// Active keymap name
51    pub active_keymap: String,
52    /// Config file path for display
53    pub config_file_path: String,
54
55    /// Custom bindings that have been added (pending save)
56    pub pending_adds: Vec<Keybinding>,
57    /// Custom bindings to remove from config (pending save)
58    pub pending_removes: Vec<Keybinding>,
59    /// Whether there are unsaved changes
60    pub has_changes: bool,
61
62    /// Showing unsaved changes confirmation dialog
63    pub showing_confirm_dialog: bool,
64    /// Selected button in confirm dialog (0=Save, 1=Discard, 2=Cancel)
65    pub confirm_selection: usize,
66
67    /// Named keymaps info for display
68    pub keymap_names: Vec<String>,
69
70    /// Available action names (for autocomplete)
71    pub available_actions: Vec<String>,
72
73    /// Mode context names (from plugins) for context filter cycling
74    pub mode_contexts: Vec<String>,
75
76    /// Display rows (section headers + binding rows) after filtering
77    pub display_rows: Vec<DisplayRow>,
78    /// Sections that are manually collapsed (by plugin name, None = builtin)
79    pub collapsed_sections: HashSet<Option<String>>,
80
81    /// Layout info for mouse hit testing (updated during render)
82    pub layout: KeybindingEditorLayout,
83}
84
85impl KeybindingEditor {
86    /// Create a new keybinding editor from config and resolver.
87    ///
88    /// `menu_names` are the stable English identifiers of top-level menus
89    /// (File, Edit, …, plus any plugin menus). They're used to enumerate
90    /// concrete `menu_open:<name>` entries in the action dropdown, so each
91    /// menu gets its own selectable row instead of one generic `menu_open`.
92    pub fn new(
93        config: &Config,
94        resolver: &KeybindingResolver,
95        mode_registry: &crate::input::buffer_mode::ModeRegistry,
96        command_registry: &CommandRegistry,
97        config_file_path: String,
98        menu_names: &[String],
99    ) -> Self {
100        let bindings =
101            Self::resolve_all_bindings(config, resolver, mode_registry, command_registry);
102        let filtered_indices: Vec<usize> = (0..bindings.len()).collect();
103
104        // Collect available action names (include plugin action names from plugin defaults)
105        let mut available_actions = Self::collect_action_names();
106        for mode_bindings in resolver.get_plugin_defaults().values() {
107            for action in mode_bindings.values() {
108                let action_name = format!("{:?}", action);
109                let action_str = match action {
110                    Action::PluginAction(name) => name.clone(),
111                    other => format!("{:?}", other),
112                };
113                if !available_actions.contains(&action_str) {
114                    available_actions.push(action_str);
115                }
116                let _ = action_name;
117            }
118        }
119        // Include action names from plugin-registered commands
120        for cmd in command_registry.get_all() {
121            if let Action::PluginAction(ref name) = cmd.action {
122                if !available_actions.contains(name) {
123                    available_actions.push(name.clone());
124                }
125            }
126        }
127
128        // Expand parameterised actions (menu_open, switch_keybinding_map) from a
129        // single bare entry — which is unparseable without args and silently
130        // becomes a no-op PluginAction — into one entry per concrete variant.
131        Self::expand_variant_actions(&mut available_actions, menu_names, config);
132
133        available_actions.sort();
134        available_actions.dedup();
135
136        // Collect keymap names
137        let mut keymap_names: Vec<String> = config.keybinding_maps.keys().cloned().collect();
138        keymap_names.sort();
139
140        // Collect mode context names from plugin defaults
141        let mut mode_contexts: Vec<String> = resolver
142            .get_plugin_defaults()
143            .keys()
144            .filter_map(|ctx| {
145                if let KeyContext::Mode(name) = ctx {
146                    Some(format!("mode:{}", name))
147                } else {
148                    None
149                }
150            })
151            .collect();
152        mode_contexts.sort();
153
154        // Collapse plugin sections by default
155        let mut collapsed_sections: HashSet<Option<String>> = HashSet::new();
156        for b in &bindings {
157            if b.plugin_name.is_some() {
158                collapsed_sections.insert(b.plugin_name.clone());
159            }
160        }
161
162        let mut editor = Self {
163            bindings,
164            filtered_indices,
165            selected: 0,
166            scroll: crate::view::ui::ScrollState::default(),
167            search_active: false,
168            search_focused: false,
169            search_query: String::new(),
170            search_mode: SearchMode::Text,
171            search_key_display: String::new(),
172            search_key_code: None,
173            search_modifiers: KeyModifiers::NONE,
174            context_filter: ContextFilter::All,
175            source_filter: SourceFilter::All,
176            edit_dialog: None,
177            showing_help: false,
178            active_keymap: config.active_keybinding_map.to_string(),
179            config_file_path,
180            pending_adds: Vec::new(),
181            pending_removes: Vec::new(),
182            has_changes: false,
183            showing_confirm_dialog: false,
184            confirm_selection: 0,
185            keymap_names,
186            available_actions,
187            mode_contexts,
188            display_rows: Vec::new(),
189            collapsed_sections,
190            layout: KeybindingEditorLayout::default(),
191        };
192
193        editor.apply_filters();
194        editor
195    }
196
197    /// Resolve all bindings from the active keymap + custom overrides + plugin modes
198    fn resolve_all_bindings(
199        config: &Config,
200        resolver: &KeybindingResolver,
201        mode_registry: &crate::input::buffer_mode::ModeRegistry,
202        command_registry: &CommandRegistry,
203    ) -> Vec<ResolvedBinding> {
204        let mut bindings = Vec::new();
205        let mut seen: HashMap<(String, String), usize> = HashMap::new(); // (key_display, context) -> index
206
207        // First, load bindings from the active keymap
208        let map_bindings = config.resolve_keymap(&config.active_keybinding_map);
209        for kb in &map_bindings {
210            if let Some(entry) = Self::keybinding_to_resolved(kb, BindingSource::Keymap, resolver) {
211                let key = (entry.key_display.clone(), entry.context.clone());
212                let idx = bindings.len();
213                seen.insert(key, idx);
214                bindings.push(entry);
215            }
216        }
217
218        // Then, load custom bindings (these override keymap bindings)
219        for kb in &config.keybindings {
220            if let Some(entry) = Self::keybinding_to_resolved(kb, BindingSource::Custom, resolver) {
221                let key = (entry.key_display.clone(), entry.context.clone());
222                if let Some(&existing_idx) = seen.get(&key) {
223                    // Override the existing binding
224                    bindings[existing_idx] = entry;
225                } else {
226                    let idx = bindings.len();
227                    seen.insert(key, idx);
228                    bindings.push(entry);
229                }
230            }
231        }
232
233        // Load plugin mode bindings from KeybindingResolver plugin_defaults
234        for (context, context_bindings) in resolver.get_plugin_defaults() {
235            if let KeyContext::Mode(mode_name) = context {
236                let context_str = format!("mode:{}", mode_name);
237                // Use plugin_name from mode registry for section grouping
238                let section = mode_registry
239                    .get(mode_name)
240                    .and_then(|m| m.plugin_name.clone())
241                    .unwrap_or_else(|| mode_name.clone());
242                for ((key_code, modifiers), action) in context_bindings {
243                    let key_display = format_keybinding(key_code, modifiers);
244                    let seen_key = (key_display.clone(), context_str.clone());
245                    // Skip if already overridden by a user custom binding
246                    if seen.contains_key(&seen_key) {
247                        continue;
248                    }
249                    let command = action.to_qualified_action_str();
250                    let action_display = KeybindingResolver::format_action(action);
251                    let idx = bindings.len();
252                    seen.insert(seen_key, idx);
253                    bindings.push(ResolvedBinding {
254                        key_display,
255                        action: command,
256                        action_display,
257                        context: context_str.clone(),
258                        source: BindingSource::Plugin,
259                        key_code: *key_code,
260                        modifiers: *modifiers,
261                        is_chord: false,
262                        plugin_name: Some(section.clone()),
263                        command_name: None,
264                        original_config: None,
265                    });
266                }
267            }
268        }
269
270        // Add entries for actions that have no keybinding
271        let bound_actions: std::collections::HashSet<String> =
272            bindings.iter().map(|b| b.action.clone()).collect();
273        for action_name in Action::all_action_names() {
274            if !bound_actions.contains(&action_name) {
275                let action_display = KeybindingResolver::format_action_from_str(&action_name);
276                bindings.push(ResolvedBinding {
277                    key_display: String::new(),
278                    action: action_name,
279                    action_display,
280                    context: String::new(),
281                    source: BindingSource::Unbound,
282                    key_code: KeyCode::Null,
283                    modifiers: KeyModifiers::NONE,
284                    is_chord: false,
285                    plugin_name: None,
286                    command_name: None,
287                    original_config: None,
288                });
289            }
290        }
291
292        // Add unbound entries for plugin-registered command actions
293        for cmd in command_registry.get_all() {
294            if let Action::PluginAction(ref action_name) = cmd.action {
295                if !bound_actions.contains(action_name) {
296                    let plugin_name = match &cmd.source {
297                        crate::input::commands::CommandSource::Plugin(name) => Some(name.clone()),
298                        _ => None,
299                    };
300                    bindings.push(ResolvedBinding {
301                        key_display: String::new(),
302                        action: action_name.clone(),
303                        action_display: cmd.get_localized_name(),
304                        context: String::new(),
305                        source: BindingSource::Unbound,
306                        key_code: KeyCode::Null,
307                        modifiers: KeyModifiers::NONE,
308                        is_chord: false,
309                        plugin_name,
310                        command_name: Some(cmd.get_localized_name()),
311                        original_config: None,
312                    });
313                }
314            }
315        }
316
317        // Populate command_name for bound plugin actions from the registry
318        {
319            let commands = command_registry.get_all();
320            let cmd_by_action: std::collections::HashMap<&str, &crate::input::commands::Command> =
321                commands
322                    .iter()
323                    .filter_map(|c| {
324                        if let Action::PluginAction(ref name) = c.action {
325                            Some((name.as_str(), c))
326                        } else {
327                            None
328                        }
329                    })
330                    .collect();
331            for binding in &mut bindings {
332                if binding.command_name.is_none() {
333                    if let Some(cmd) = cmd_by_action.get(binding.action.as_str()) {
334                        let name = cmd.get_localized_name();
335                        // Use the command name as the display description so it
336                        // matches what users see in the command palette.
337                        binding.action_display = name.clone();
338                        binding.command_name = Some(name);
339                    }
340                }
341            }
342        }
343
344        // Sort by plugin_name (None/builtin first), then context, then action name
345        bindings.sort_by(|a, b| {
346            a.plugin_name
347                .cmp(&b.plugin_name)
348                .then(a.context.cmp(&b.context))
349                .then(a.action_display.cmp(&b.action_display))
350        });
351
352        bindings
353    }
354
355    /// Convert a Keybinding config entry to a ResolvedBinding
356    fn keybinding_to_resolved(
357        kb: &Keybinding,
358        source: BindingSource,
359        _resolver: &KeybindingResolver,
360    ) -> Option<ResolvedBinding> {
361        let context = kb.when.as_deref().unwrap_or("normal").to_string();
362
363        // Store the qualified form (e.g. `menu_open:File`) on ResolvedBinding
364        // so the dropdown round-trips faithfully and "still-bound" checks
365        // distinguish variants of the same bare action.
366        let qualified_action = Action::qualify_action(&kb.action, &kb.args);
367
368        if !kb.keys.is_empty() {
369            // Chord binding
370            let key_display = format_chord_keys(&kb.keys);
371            let action_display =
372                KeybindingResolver::format_action_from_str_with_args(&kb.action, &kb.args);
373            let original_config = if source == BindingSource::Custom {
374                Some(kb.clone())
375            } else {
376                None
377            };
378            Some(ResolvedBinding {
379                key_display,
380                action: qualified_action,
381                action_display,
382                context,
383                source,
384                key_code: KeyCode::Null,
385                modifiers: KeyModifiers::NONE,
386                is_chord: true,
387                plugin_name: None,
388                command_name: None,
389                original_config,
390            })
391        } else if !kb.key.is_empty() {
392            // Single key binding
393            let key_code = KeybindingResolver::parse_key_public(&kb.key)?;
394            let modifiers = KeybindingResolver::parse_modifiers_public(&kb.modifiers);
395            let key_display = format_keybinding(&key_code, &modifiers);
396            let action_display =
397                KeybindingResolver::format_action_from_str_with_args(&kb.action, &kb.args);
398            let original_config = if source == BindingSource::Custom {
399                Some(kb.clone())
400            } else {
401                None
402            };
403            Some(ResolvedBinding {
404                key_display,
405                action: qualified_action,
406                action_display,
407                context,
408                source,
409                key_code,
410                modifiers,
411                is_chord: false,
412                plugin_name: None,
413                command_name: None,
414                original_config,
415            })
416        } else {
417            None
418        }
419    }
420
421    /// Collect all available action names (delegates to the macro-generated source of truth)
422    fn collect_action_names() -> Vec<String> {
423        Action::all_action_names()
424    }
425
426    /// Replace bare entries for parameterised actions (`menu_open`,
427    /// `switch_keybinding_map`) with one qualified entry per variant — e.g.
428    /// `menu_open:File`, `menu_open:Edit`. Without this, picking the bare
429    /// `menu_open` from the dropdown would produce an un-parseable binding
430    /// because `Action::from_str` requires the args map to carry the menu
431    /// name.
432    fn expand_variant_actions(actions: &mut Vec<String>, menu_names: &[String], config: &Config) {
433        // Menu names: built-in + plugin, deduplicated case-insensitively.
434        let mut menus: Vec<String> = menu_names.to_vec();
435        menus.sort();
436        menus.dedup();
437        actions.retain(|a| a != "menu_open");
438        for name in &menus {
439            actions.push(format!("menu_open:{}", name));
440        }
441
442        // Keybinding maps: the four built-ins plus user-defined.
443        let mut keymaps: Vec<String> = ["default", "emacs", "vscode", "macos"]
444            .map(String::from)
445            .to_vec();
446        keymaps.extend(config.keybinding_maps.keys().cloned());
447        keymaps.sort();
448        keymaps.dedup();
449        actions.retain(|a| a != "switch_keybinding_map");
450        for map in &keymaps {
451            actions.push(format!("switch_keybinding_map:{}", map));
452        }
453    }
454
455    /// Update autocomplete suggestions based on current action text
456    pub fn update_autocomplete(&mut self) {
457        if let Some(ref mut dialog) = self.edit_dialog {
458            let query = dialog.action_text.to_lowercase();
459            if query.is_empty() {
460                dialog.autocomplete_suggestions.clear();
461                dialog.autocomplete_visible = false;
462                dialog.autocomplete_selected = None;
463                return;
464            }
465
466            dialog.autocomplete_suggestions = self
467                .available_actions
468                .iter()
469                .filter(|a| a.to_lowercase().contains(&query))
470                .cloned()
471                .collect();
472
473            // Sort: exact prefix matches first, then contains matches
474            let q = query.clone();
475            dialog.autocomplete_suggestions.sort_by(|a, b| {
476                let a_prefix = a.to_lowercase().starts_with(&q);
477                let b_prefix = b.to_lowercase().starts_with(&q);
478                match (a_prefix, b_prefix) {
479                    (true, false) => std::cmp::Ordering::Less,
480                    (false, true) => std::cmp::Ordering::Greater,
481                    _ => a.cmp(b),
482                }
483            });
484
485            dialog.autocomplete_visible = !dialog.autocomplete_suggestions.is_empty();
486            // Reset selection when text changes
487            dialog.autocomplete_selected = if dialog.autocomplete_visible {
488                Some(0)
489            } else {
490                None
491            };
492            // Clear any previous error
493            dialog.action_error = None;
494        }
495    }
496
497    /// Check if the given action name is valid
498    pub fn is_valid_action(&self, action_name: &str) -> bool {
499        self.available_actions.iter().any(|a| a == action_name)
500    }
501
502    /// Apply current search and filter criteria
503    pub fn apply_filters(&mut self) {
504        self.filtered_indices.clear();
505
506        for (i, binding) in self.bindings.iter().enumerate() {
507            // Apply context filter
508            if let ContextFilter::Specific(ref ctx) = self.context_filter {
509                if &binding.context != ctx {
510                    continue;
511                }
512            }
513
514            // Apply source filter
515            match self.source_filter {
516                SourceFilter::KeymapOnly if binding.source != BindingSource::Keymap => continue,
517                SourceFilter::CustomOnly if binding.source != BindingSource::Custom => continue,
518                SourceFilter::PluginOnly if binding.source != BindingSource::Plugin => continue,
519                _ => {}
520            }
521
522            // Apply search
523            if self.search_active {
524                match self.search_mode {
525                    SearchMode::Text => {
526                        if !self.search_query.is_empty() {
527                            let query = self.search_query.to_lowercase();
528                            let matches = binding.action.to_lowercase().contains(&query)
529                                || binding.action_display.to_lowercase().contains(&query)
530                                || binding.key_display.to_lowercase().contains(&query)
531                                || binding.context.to_lowercase().contains(&query)
532                                || binding
533                                    .command_name
534                                    .as_ref()
535                                    .is_some_and(|n| n.to_lowercase().contains(&query));
536                            if !matches {
537                                continue;
538                            }
539                        }
540                    }
541                    SearchMode::RecordKey => {
542                        if let Some(search_key) = self.search_key_code {
543                            if !binding.is_chord {
544                                let key_matches = binding.key_code == search_key
545                                    && binding.modifiers == self.search_modifiers;
546                                if !key_matches {
547                                    continue;
548                                }
549                            } else {
550                                continue; // Skip chords in key search mode
551                            }
552                        }
553                    }
554                }
555            }
556
557            self.filtered_indices.push(i);
558        }
559
560        // Build display rows with section headers
561        self.build_display_rows();
562
563        // Reset selection if it's out of bounds
564        if self.selected >= self.display_rows.len() {
565            self.selected = self.display_rows.len().saturating_sub(1);
566        }
567        self.ensure_visible();
568    }
569
570    /// Build display rows from filtered indices, inserting section headers
571    fn build_display_rows(&mut self) {
572        self.display_rows.clear();
573
574        let has_active_filter = (self.search_active
575            && match self.search_mode {
576                SearchMode::Text => !self.search_query.is_empty(),
577                SearchMode::RecordKey => self.search_key_code.is_some(),
578            })
579            || !matches!(self.context_filter, ContextFilter::All)
580            || !matches!(self.source_filter, SourceFilter::All);
581
582        // Group filtered indices by section (plugin_name)
583        let mut sections: Vec<(Option<String>, Vec<usize>)> = Vec::new();
584        let mut current_section: Option<&Option<String>> = None;
585
586        for &idx in &self.filtered_indices {
587            let binding = &self.bindings[idx];
588            if current_section != Some(&binding.plugin_name) {
589                sections.push((binding.plugin_name.clone(), Vec::new()));
590                current_section = Some(&binding.plugin_name);
591            }
592            sections.last_mut().unwrap().1.push(idx);
593        }
594
595        for (plugin_name, indices) in sections {
596            // When filtering, hide sections with zero matches (already filtered out)
597            // When searching, auto-expand all sections that have matches
598            let collapsed = if has_active_filter {
599                false
600            } else {
601                self.collapsed_sections.contains(&plugin_name)
602            };
603
604            self.display_rows.push(DisplayRow::SectionHeader {
605                plugin_name: plugin_name.clone(),
606                collapsed,
607                binding_count: indices.len(),
608            });
609
610            if !collapsed {
611                for idx in indices {
612                    self.display_rows.push(DisplayRow::Binding(idx));
613                }
614            }
615        }
616    }
617
618    /// Toggle the collapsed state of the section at the current selection
619    pub fn toggle_section_at_selected(&mut self) {
620        if let Some(DisplayRow::SectionHeader { plugin_name, .. }) =
621            self.display_rows.get(self.selected)
622        {
623            let key = plugin_name.clone();
624            if self.collapsed_sections.contains(&key) {
625                self.collapsed_sections.remove(&key);
626            } else {
627                self.collapsed_sections.insert(key);
628            }
629            self.build_display_rows();
630            // Keep selected in bounds
631            if self.selected >= self.display_rows.len() {
632                self.selected = self.display_rows.len().saturating_sub(1);
633            }
634            self.ensure_visible();
635        }
636    }
637
638    /// Check if the currently selected display row is a section header
639    pub fn selected_is_section_header(&self) -> bool {
640        matches!(
641            self.display_rows.get(self.selected),
642            Some(DisplayRow::SectionHeader { .. })
643        )
644    }
645
646    /// Get the currently selected binding (None if a section header is selected)
647    pub fn selected_binding(&self) -> Option<&ResolvedBinding> {
648        match self.display_rows.get(self.selected) {
649            Some(DisplayRow::Binding(idx)) => self.bindings.get(*idx),
650            _ => None,
651        }
652    }
653
654    /// Get the binding index in `self.bindings` for the current selection
655    fn selected_binding_index(&self) -> Option<usize> {
656        match self.display_rows.get(self.selected) {
657            Some(DisplayRow::Binding(idx)) => Some(*idx),
658            _ => None,
659        }
660    }
661
662    /// Move selection up
663    pub fn select_prev(&mut self) {
664        if self.selected > 0 {
665            self.selected -= 1;
666            self.ensure_visible();
667        }
668    }
669
670    /// Move selection down
671    pub fn select_next(&mut self) {
672        if self.selected + 1 < self.display_rows.len() {
673            self.selected += 1;
674            self.ensure_visible();
675        }
676    }
677
678    /// Page up
679    pub fn page_up(&mut self) {
680        let page = self.scroll.viewport as usize;
681        if self.selected > page {
682            self.selected -= page;
683        } else {
684            self.selected = 0;
685        }
686        self.ensure_visible();
687    }
688
689    /// Page down
690    pub fn page_down(&mut self) {
691        let page = self.scroll.viewport as usize;
692        self.selected = (self.selected + page).min(self.display_rows.len().saturating_sub(1));
693        self.ensure_visible();
694    }
695
696    /// Ensure the selected item is visible (public version)
697    pub fn ensure_visible_public(&mut self) {
698        self.ensure_visible();
699    }
700
701    /// Ensure the selected item is visible
702    fn ensure_visible(&mut self) {
703        self.scroll.ensure_visible(self.selected as u16, 1);
704    }
705
706    /// Start text search (preserves existing query when re-focusing)
707    pub fn start_search(&mut self) {
708        if !self.search_active || self.search_mode != SearchMode::Text {
709            // Starting fresh or switching from record mode
710            self.search_mode = SearchMode::Text;
711            if !self.search_active {
712                self.search_query.clear();
713            }
714        }
715        self.search_active = true;
716        self.search_focused = true;
717    }
718
719    /// Start record-key search
720    pub fn start_record_key_search(&mut self) {
721        self.search_active = true;
722        self.search_focused = true;
723        self.search_mode = SearchMode::RecordKey;
724        self.search_key_display.clear();
725        self.search_key_code = None;
726        self.search_modifiers = KeyModifiers::NONE;
727    }
728
729    /// Cancel search (clear everything)
730    pub fn cancel_search(&mut self) {
731        self.search_active = false;
732        self.search_focused = false;
733        self.search_query.clear();
734        self.search_key_code = None;
735        self.search_key_display.clear();
736        self.apply_filters();
737    }
738
739    /// Record a search key
740    pub fn record_search_key(&mut self, event: &KeyEvent) {
741        self.search_key_code = Some(event.code);
742        self.search_modifiers = event.modifiers;
743        self.search_key_display = format_keybinding(&event.code, &event.modifiers);
744        self.apply_filters();
745    }
746
747    /// Cycle context filter
748    pub fn cycle_context_filter(&mut self) {
749        let mut contexts = vec![
750            ContextFilter::All,
751            ContextFilter::Specific("global".to_string()),
752            ContextFilter::Specific("normal".to_string()),
753            ContextFilter::Specific("prompt".to_string()),
754            ContextFilter::Specific("popup".to_string()),
755            ContextFilter::Specific("file_explorer".to_string()),
756            ContextFilter::Specific("menu".to_string()),
757            ContextFilter::Specific("terminal".to_string()),
758        ];
759        // Add mode contexts dynamically
760        for mode_ctx in &self.mode_contexts {
761            contexts.push(ContextFilter::Specific(mode_ctx.clone()));
762        }
763
764        let current_idx = contexts
765            .iter()
766            .position(|c| c == &self.context_filter)
767            .unwrap_or(0);
768        let next_idx = (current_idx + 1) % contexts.len();
769        self.context_filter = contexts.into_iter().nth(next_idx).unwrap();
770        self.apply_filters();
771    }
772
773    /// Cycle source filter
774    pub fn cycle_source_filter(&mut self) {
775        self.source_filter = match self.source_filter {
776            SourceFilter::All => SourceFilter::CustomOnly,
777            SourceFilter::CustomOnly => SourceFilter::KeymapOnly,
778            SourceFilter::KeymapOnly => SourceFilter::PluginOnly,
779            SourceFilter::PluginOnly => SourceFilter::All,
780        };
781        self.apply_filters();
782    }
783
784    /// Open the add binding dialog
785    pub fn open_add_dialog(&mut self) {
786        self.edit_dialog = Some(EditBindingState::new_add_with_modes(&self.mode_contexts));
787    }
788
789    /// Open the edit binding dialog for the selected binding
790    pub fn open_edit_dialog(&mut self) {
791        if let Some(idx) = self.selected_binding_index() {
792            let binding = self.bindings[idx].clone();
793            self.edit_dialog = Some(EditBindingState::new_edit_with_modes(
794                idx,
795                &binding,
796                &self.mode_contexts,
797            ));
798        }
799    }
800
801    /// Close the edit dialog
802    pub fn close_edit_dialog(&mut self) {
803        self.edit_dialog = None;
804    }
805
806    /// Delete the selected binding.
807    ///
808    /// * **Custom** bindings are removed outright (tracked in `pending_removes`
809    ///   or dropped from `pending_adds` when added in the same session).
810    /// * **Keymap** bindings cannot be removed from the built-in map, so a
811    ///   custom `noop` override is created for the same key, which shadows the
812    ///   default binding in the resolver.
813    ///
814    /// Returns `DeleteResult` indicating what happened.
815    pub fn delete_selected(&mut self) -> DeleteResult {
816        let Some(idx) = self.selected_binding_index() else {
817            return DeleteResult::NothingSelected;
818        };
819
820        match self.bindings[idx].source {
821            BindingSource::Custom => {
822                let binding = &self.bindings[idx];
823                let action_name = binding.action.clone();
824
825                // Use the original config-level Keybinding if available (for
826                // bindings loaded from config), otherwise reconstruct it.
827                // This avoids lossy round-trips through parse_key which
828                // lowercases key names (e.g. "N" → "n").
829                let config_kb = binding
830                    .original_config
831                    .clone()
832                    .unwrap_or_else(|| self.resolved_to_config_keybinding(binding));
833
834                // If this binding was added in the current session, just
835                // remove it from pending_adds. Otherwise track for removal
836                // from the persisted config.
837                let found_in_adds = self.pending_adds.iter().position(|kb| {
838                    kb.action == config_kb.action
839                        && kb.key == config_kb.key
840                        && kb.modifiers == config_kb.modifiers
841                        && kb.when == config_kb.when
842                });
843                if let Some(pos) = found_in_adds {
844                    self.pending_adds.remove(pos);
845                } else {
846                    self.pending_removes.push(config_kb);
847                }
848
849                self.bindings.remove(idx);
850                self.has_changes = true;
851
852                // If no other binding exists for this action, re-add as unbound
853                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
854                if !still_bound {
855                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
856                    self.bindings.push(ResolvedBinding {
857                        key_display: String::new(),
858                        action: action_name,
859                        action_display,
860                        context: String::new(),
861                        source: BindingSource::Unbound,
862                        key_code: KeyCode::Null,
863                        modifiers: KeyModifiers::NONE,
864                        is_chord: false,
865                        plugin_name: None,
866                        command_name: None,
867                        original_config: None,
868                    });
869                }
870
871                self.apply_filters();
872                DeleteResult::CustomRemoved
873            }
874            BindingSource::Keymap => {
875                let binding = &self.bindings[idx];
876                let action_name = binding.action.clone();
877
878                // Build a noop custom override for the same key+context
879                let noop_kb = Keybinding {
880                    key: if binding.is_chord {
881                        String::new()
882                    } else {
883                        key_code_to_config_name(binding.key_code)
884                    },
885                    modifiers: if binding.is_chord {
886                        Vec::new()
887                    } else {
888                        modifiers_to_config_names(binding.modifiers)
889                    },
890                    keys: Vec::new(),
891                    action: "noop".to_string(),
892                    args: HashMap::new(),
893                    when: if binding.context.is_empty() {
894                        None
895                    } else {
896                        Some(binding.context.clone())
897                    },
898                };
899                self.pending_adds.push(noop_kb);
900
901                // Replace the keymap entry with a noop custom entry in the display
902                let noop_display = KeybindingResolver::format_action_from_str("noop");
903                self.bindings[idx] = ResolvedBinding {
904                    key_display: self.bindings[idx].key_display.clone(),
905                    action: "noop".to_string(),
906                    action_display: noop_display,
907                    context: self.bindings[idx].context.clone(),
908                    source: BindingSource::Custom,
909                    key_code: self.bindings[idx].key_code,
910                    modifiers: self.bindings[idx].modifiers,
911                    is_chord: self.bindings[idx].is_chord,
912                    plugin_name: self.bindings[idx].plugin_name.clone(),
913                    command_name: None,
914                    original_config: None,
915                };
916                self.has_changes = true;
917
918                // The original action may now be unbound
919                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
920                if !still_bound {
921                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
922                    self.bindings.push(ResolvedBinding {
923                        key_display: String::new(),
924                        action: action_name,
925                        action_display,
926                        context: String::new(),
927                        source: BindingSource::Unbound,
928                        key_code: KeyCode::Null,
929                        modifiers: KeyModifiers::NONE,
930                        is_chord: false,
931                        plugin_name: None,
932                        command_name: None,
933                        original_config: None,
934                    });
935                }
936
937                self.apply_filters();
938                DeleteResult::KeymapOverridden
939            }
940            BindingSource::Plugin => {
941                // Plugin bindings behave like keymap bindings - create a noop override
942                let binding = &self.bindings[idx];
943                let action_name = binding.action.clone();
944
945                let noop_kb = Keybinding {
946                    key: if binding.is_chord {
947                        String::new()
948                    } else {
949                        key_code_to_config_name(binding.key_code)
950                    },
951                    modifiers: if binding.is_chord {
952                        Vec::new()
953                    } else {
954                        modifiers_to_config_names(binding.modifiers)
955                    },
956                    keys: Vec::new(),
957                    action: "noop".to_string(),
958                    args: HashMap::new(),
959                    when: if binding.context.is_empty() {
960                        None
961                    } else {
962                        Some(binding.context.clone())
963                    },
964                };
965                self.pending_adds.push(noop_kb);
966
967                let noop_display = KeybindingResolver::format_action_from_str("noop");
968                self.bindings[idx] = ResolvedBinding {
969                    key_display: self.bindings[idx].key_display.clone(),
970                    action: "noop".to_string(),
971                    action_display: noop_display,
972                    context: self.bindings[idx].context.clone(),
973                    source: BindingSource::Custom,
974                    key_code: self.bindings[idx].key_code,
975                    modifiers: self.bindings[idx].modifiers,
976                    is_chord: self.bindings[idx].is_chord,
977                    plugin_name: self.bindings[idx].plugin_name.clone(),
978                    command_name: None,
979                    original_config: None,
980                };
981                self.has_changes = true;
982
983                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
984                if !still_bound {
985                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
986                    self.bindings.push(ResolvedBinding {
987                        key_display: String::new(),
988                        action: action_name,
989                        action_display,
990                        context: String::new(),
991                        source: BindingSource::Unbound,
992                        key_code: KeyCode::Null,
993                        modifiers: KeyModifiers::NONE,
994                        is_chord: false,
995                        plugin_name: None,
996                        command_name: None,
997                        original_config: None,
998                    });
999                }
1000
1001                self.apply_filters();
1002                DeleteResult::KeymapOverridden
1003            }
1004            BindingSource::Unbound => DeleteResult::CannotDelete,
1005        }
1006    }
1007
1008    /// Convert a ResolvedBinding to a config-level Keybinding (for matching).
1009    fn resolved_to_config_keybinding(&self, binding: &ResolvedBinding) -> Keybinding {
1010        let (action, args) = Action::unqualify_action(&binding.action);
1011        Keybinding {
1012            key: if binding.is_chord {
1013                String::new()
1014            } else {
1015                key_code_to_config_name(binding.key_code)
1016            },
1017            modifiers: if binding.is_chord {
1018                Vec::new()
1019            } else {
1020                modifiers_to_config_names(binding.modifiers)
1021            },
1022            keys: Vec::new(),
1023            action,
1024            args,
1025            when: if binding.context.is_empty() {
1026                None
1027            } else {
1028                Some(binding.context.clone())
1029            },
1030        }
1031    }
1032
1033    /// Apply the edit dialog to create/update a binding.
1034    /// Returns an error message if validation fails.
1035    pub fn apply_edit_dialog(&mut self) -> Option<String> {
1036        let dialog = self.edit_dialog.take()?;
1037
1038        if dialog.key_code.is_none() || dialog.action_text.is_empty() {
1039            self.edit_dialog = Some(dialog);
1040            return Some(t!("keybinding_editor.error_key_action_required").to_string());
1041        }
1042
1043        // Validate the action name
1044        if !self.is_valid_action(&dialog.action_text) {
1045            let err_msg = t!(
1046                "keybinding_editor.error_unknown_action",
1047                action = &dialog.action_text
1048            )
1049            .to_string();
1050            let mut dialog = dialog;
1051            dialog.action_error = Some(
1052                t!(
1053                    "keybinding_editor.error_unknown_action_short",
1054                    action = &dialog.action_text
1055                )
1056                .to_string(),
1057            );
1058            self.edit_dialog = Some(dialog);
1059            return Some(err_msg);
1060        }
1061
1062        let key_code = dialog.key_code.unwrap();
1063        let modifiers = dialog.modifiers;
1064        let key_name = key_code_to_config_name(key_code);
1065        let modifier_names = modifiers_to_config_names(modifiers);
1066
1067        // Split the qualified form (e.g. `menu_open:File`) into bare action +
1068        // args so the written Keybinding actually parses back to the right
1069        // variant at runtime.
1070        let (bare_action, args) = Action::unqualify_action(&dialog.action_text);
1071
1072        let new_binding = Keybinding {
1073            key: key_name,
1074            modifiers: modifier_names,
1075            keys: Vec::new(),
1076            action: bare_action.clone(),
1077            args: args.clone(),
1078            when: Some(dialog.context.clone()),
1079        };
1080
1081        // Add as custom binding
1082        self.pending_adds.push(new_binding.clone());
1083        self.has_changes = true;
1084
1085        // Update display
1086        let key_display = format_keybinding(&key_code, &modifiers);
1087        let action_display =
1088            KeybindingResolver::format_action_from_str_with_args(&bare_action, &args);
1089
1090        // When editing an existing binding, preserve its plugin_name so it stays
1091        // in the same section. New bindings go to Builtin (plugin_name: None).
1092        let preserved_plugin_name = dialog
1093            .editing_index
1094            .and_then(|idx| self.bindings.get(idx))
1095            .and_then(|b| b.plugin_name.clone());
1096
1097        let resolved = ResolvedBinding {
1098            key_display,
1099            action: dialog.action_text,
1100            action_display,
1101            context: dialog.context,
1102            source: BindingSource::Custom,
1103            key_code,
1104            modifiers,
1105            is_chord: false,
1106            plugin_name: preserved_plugin_name,
1107            command_name: None,
1108            original_config: None,
1109        };
1110
1111        if let Some(edit_idx) = dialog.editing_index {
1112            // Editing existing - replace it
1113            if edit_idx < self.bindings.len() {
1114                self.bindings[edit_idx] = resolved;
1115            }
1116        } else {
1117            // Adding new
1118            self.bindings.push(resolved);
1119        }
1120
1121        self.apply_filters();
1122        None
1123    }
1124
1125    /// Check for conflicts with the given key combination
1126    pub fn find_conflicts(
1127        &self,
1128        key_code: KeyCode,
1129        modifiers: KeyModifiers,
1130        context: &str,
1131    ) -> Vec<String> {
1132        let mut conflicts = Vec::new();
1133
1134        for binding in &self.bindings {
1135            if !binding.is_chord
1136                && binding.key_code == key_code
1137                && binding.modifiers == modifiers
1138                && (binding.context == context
1139                    || binding.context == "global"
1140                    || context == "global")
1141            {
1142                conflicts.push(format!(
1143                    "{} ({}, {})",
1144                    binding.action_display,
1145                    binding.context,
1146                    match binding.source {
1147                        BindingSource::Custom => "custom",
1148                        BindingSource::Plugin => "plugin",
1149                        _ => "keymap",
1150                    }
1151                ));
1152            }
1153        }
1154
1155        conflicts
1156    }
1157
1158    /// Get the custom bindings to save to config
1159    pub fn get_custom_bindings(&self) -> Vec<Keybinding> {
1160        self.pending_adds.clone()
1161    }
1162
1163    /// Get the custom bindings to remove from config
1164    pub fn get_pending_removes(&self) -> &[Keybinding] {
1165        &self.pending_removes
1166    }
1167
1168    /// Get the context filter display string
1169    pub fn context_filter_display(&self) -> &str {
1170        match &self.context_filter {
1171            ContextFilter::All => "All",
1172            ContextFilter::Specific(ctx) => ctx.as_str(),
1173        }
1174    }
1175
1176    /// Get the source filter display string
1177    pub fn source_filter_display(&self) -> &str {
1178        match &self.source_filter {
1179            SourceFilter::All => "All",
1180            SourceFilter::KeymapOnly => "Keymap",
1181            SourceFilter::CustomOnly => "Custom",
1182            SourceFilter::PluginOnly => "Plugin",
1183        }
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use crate::input::buffer_mode::ModeRegistry;
1191
1192    fn make_editor(extra_menus: &[&str]) -> KeybindingEditor {
1193        let config = Config::default();
1194        let resolver = KeybindingResolver::new(&config);
1195        let mode_registry = ModeRegistry::new();
1196        let cmd_registry = CommandRegistry::new();
1197        let mut menu_names: Vec<String> = ["File", "Edit", "View"]
1198            .iter()
1199            .map(|s| s.to_string())
1200            .collect();
1201        menu_names.extend(extra_menus.iter().map(|s| s.to_string()));
1202        KeybindingEditor::new(
1203            &config,
1204            &resolver,
1205            &mode_registry,
1206            &cmd_registry,
1207            String::from("/tmp/fresh-config.toml"),
1208            &menu_names,
1209        )
1210    }
1211
1212    #[test]
1213    fn dropdown_lists_menu_open_variants_not_bare_entry() {
1214        // Regression for #1407 follow-up: picking `menu_open` from the
1215        // dropdown used to produce a no-op binding because no args were
1216        // attached. The dropdown should instead offer one entry per menu.
1217        let editor = make_editor(&[]);
1218        assert!(
1219            !editor.available_actions.iter().any(|a| a == "menu_open"),
1220            "bare `menu_open` must not appear — it is un-parseable without args"
1221        );
1222        assert!(
1223            editor
1224                .available_actions
1225                .contains(&"menu_open:File".to_string()),
1226            "expected dropdown to list `menu_open:File`, got {:?}",
1227            editor.available_actions
1228        );
1229        assert!(
1230            editor
1231                .available_actions
1232                .contains(&"menu_open:Edit".to_string()),
1233            "expected dropdown to list `menu_open:Edit`"
1234        );
1235    }
1236
1237    #[test]
1238    fn dropdown_includes_plugin_menus_passed_in() {
1239        let editor = make_editor(&["MyPluginMenu"]);
1240        assert!(
1241            editor
1242                .available_actions
1243                .contains(&"menu_open:MyPluginMenu".to_string()),
1244            "plugin menus should surface as dropdown entries"
1245        );
1246    }
1247
1248    #[test]
1249    fn dropdown_lists_builtin_keybinding_maps() {
1250        let editor = make_editor(&[]);
1251        for map in ["default", "emacs", "vscode", "macos"] {
1252            let qualified = format!("switch_keybinding_map:{}", map);
1253            assert!(
1254                editor.available_actions.contains(&qualified),
1255                "expected `{}` in dropdown",
1256                qualified
1257            );
1258        }
1259        assert!(
1260            !editor
1261                .available_actions
1262                .iter()
1263                .any(|a| a == "switch_keybinding_map"),
1264            "bare `switch_keybinding_map` must not appear"
1265        );
1266    }
1267
1268    #[test]
1269    fn qualified_action_roundtrips_through_resolved_to_config() {
1270        // A binding selected from the dropdown as `menu_open:File` must be
1271        // written to config as `{action: "menu_open", args: {name: "File"}}`.
1272        let editor = make_editor(&[]);
1273        let rb = ResolvedBinding {
1274            key_display: "Alt+F".to_string(),
1275            action: "menu_open:File".to_string(),
1276            action_display: String::new(),
1277            context: "global".to_string(),
1278            source: BindingSource::Custom,
1279            key_code: KeyCode::Char('f'),
1280            modifiers: KeyModifiers::ALT,
1281            is_chord: false,
1282            plugin_name: None,
1283            command_name: None,
1284            original_config: None,
1285        };
1286        let kb = editor.resolved_to_config_keybinding(&rb);
1287        assert_eq!(kb.action, "menu_open");
1288        assert_eq!(
1289            kb.args.get("name").and_then(|v| v.as_str()),
1290            Some("File"),
1291            "the variant name must land in args.name, got {:?}",
1292            kb.args
1293        );
1294    }
1295}