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("completion".to_string()),
756            ContextFilter::Specific("file_explorer".to_string()),
757            ContextFilter::Specific("menu".to_string()),
758            ContextFilter::Specific("terminal".to_string()),
759        ];
760        // Add mode contexts dynamically
761        for mode_ctx in &self.mode_contexts {
762            contexts.push(ContextFilter::Specific(mode_ctx.clone()));
763        }
764
765        let current_idx = contexts
766            .iter()
767            .position(|c| c == &self.context_filter)
768            .unwrap_or(0);
769        let next_idx = (current_idx + 1) % contexts.len();
770        self.context_filter = contexts.into_iter().nth(next_idx).unwrap();
771        self.apply_filters();
772    }
773
774    /// Cycle source filter
775    pub fn cycle_source_filter(&mut self) {
776        self.source_filter = match self.source_filter {
777            SourceFilter::All => SourceFilter::CustomOnly,
778            SourceFilter::CustomOnly => SourceFilter::KeymapOnly,
779            SourceFilter::KeymapOnly => SourceFilter::PluginOnly,
780            SourceFilter::PluginOnly => SourceFilter::All,
781        };
782        self.apply_filters();
783    }
784
785    /// Open the add binding dialog
786    pub fn open_add_dialog(&mut self) {
787        self.edit_dialog = Some(EditBindingState::new_add_with_modes(&self.mode_contexts));
788    }
789
790    /// Open the edit binding dialog for the selected binding
791    pub fn open_edit_dialog(&mut self) {
792        if let Some(idx) = self.selected_binding_index() {
793            let binding = self.bindings[idx].clone();
794            self.edit_dialog = Some(EditBindingState::new_edit_with_modes(
795                idx,
796                &binding,
797                &self.mode_contexts,
798            ));
799        }
800    }
801
802    /// Close the edit dialog
803    pub fn close_edit_dialog(&mut self) {
804        self.edit_dialog = None;
805    }
806
807    /// Delete the selected binding.
808    ///
809    /// * **Custom** bindings are removed outright (tracked in `pending_removes`
810    ///   or dropped from `pending_adds` when added in the same session).
811    /// * **Keymap** bindings cannot be removed from the built-in map, so a
812    ///   custom `noop` override is created for the same key, which shadows the
813    ///   default binding in the resolver.
814    ///
815    /// Returns `DeleteResult` indicating what happened.
816    pub fn delete_selected(&mut self) -> DeleteResult {
817        let Some(idx) = self.selected_binding_index() else {
818            return DeleteResult::NothingSelected;
819        };
820
821        match self.bindings[idx].source {
822            BindingSource::Custom => {
823                let binding = &self.bindings[idx];
824                let action_name = binding.action.clone();
825
826                // Use the original config-level Keybinding if available (for
827                // bindings loaded from config), otherwise reconstruct it.
828                // This avoids lossy round-trips through parse_key which
829                // lowercases key names (e.g. "N" → "n").
830                let config_kb = binding
831                    .original_config
832                    .clone()
833                    .unwrap_or_else(|| self.resolved_to_config_keybinding(binding));
834
835                // If this binding was added in the current session, just
836                // remove it from pending_adds. Otherwise track for removal
837                // from the persisted config.
838                let found_in_adds = self.pending_adds.iter().position(|kb| {
839                    kb.action == config_kb.action
840                        && kb.key == config_kb.key
841                        && kb.modifiers == config_kb.modifiers
842                        && kb.when == config_kb.when
843                });
844                if let Some(pos) = found_in_adds {
845                    self.pending_adds.remove(pos);
846                } else {
847                    self.pending_removes.push(config_kb);
848                }
849
850                self.bindings.remove(idx);
851                self.has_changes = true;
852
853                // If no other binding exists for this action, re-add as unbound
854                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
855                if !still_bound {
856                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
857                    self.bindings.push(ResolvedBinding {
858                        key_display: String::new(),
859                        action: action_name,
860                        action_display,
861                        context: String::new(),
862                        source: BindingSource::Unbound,
863                        key_code: KeyCode::Null,
864                        modifiers: KeyModifiers::NONE,
865                        is_chord: false,
866                        plugin_name: None,
867                        command_name: None,
868                        original_config: None,
869                    });
870                }
871
872                self.apply_filters();
873                DeleteResult::CustomRemoved
874            }
875            BindingSource::Keymap => {
876                let binding = &self.bindings[idx];
877                let action_name = binding.action.clone();
878
879                // Build a noop custom override for the same key+context
880                let noop_kb = Keybinding {
881                    key: if binding.is_chord {
882                        String::new()
883                    } else {
884                        key_code_to_config_name(binding.key_code)
885                    },
886                    modifiers: if binding.is_chord {
887                        Vec::new()
888                    } else {
889                        modifiers_to_config_names(binding.modifiers)
890                    },
891                    keys: Vec::new(),
892                    action: "noop".to_string(),
893                    args: HashMap::new(),
894                    when: if binding.context.is_empty() {
895                        None
896                    } else {
897                        Some(binding.context.clone())
898                    },
899                };
900                self.pending_adds.push(noop_kb);
901
902                // Replace the keymap entry with a noop custom entry in the display
903                let noop_display = KeybindingResolver::format_action_from_str("noop");
904                self.bindings[idx] = ResolvedBinding {
905                    key_display: self.bindings[idx].key_display.clone(),
906                    action: "noop".to_string(),
907                    action_display: noop_display,
908                    context: self.bindings[idx].context.clone(),
909                    source: BindingSource::Custom,
910                    key_code: self.bindings[idx].key_code,
911                    modifiers: self.bindings[idx].modifiers,
912                    is_chord: self.bindings[idx].is_chord,
913                    plugin_name: self.bindings[idx].plugin_name.clone(),
914                    command_name: None,
915                    original_config: None,
916                };
917                self.has_changes = true;
918
919                // The original action may now be unbound
920                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
921                if !still_bound {
922                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
923                    self.bindings.push(ResolvedBinding {
924                        key_display: String::new(),
925                        action: action_name,
926                        action_display,
927                        context: String::new(),
928                        source: BindingSource::Unbound,
929                        key_code: KeyCode::Null,
930                        modifiers: KeyModifiers::NONE,
931                        is_chord: false,
932                        plugin_name: None,
933                        command_name: None,
934                        original_config: None,
935                    });
936                }
937
938                self.apply_filters();
939                DeleteResult::KeymapOverridden
940            }
941            BindingSource::Plugin => {
942                // Plugin bindings behave like keymap bindings - create a noop override
943                let binding = &self.bindings[idx];
944                let action_name = binding.action.clone();
945
946                let noop_kb = Keybinding {
947                    key: if binding.is_chord {
948                        String::new()
949                    } else {
950                        key_code_to_config_name(binding.key_code)
951                    },
952                    modifiers: if binding.is_chord {
953                        Vec::new()
954                    } else {
955                        modifiers_to_config_names(binding.modifiers)
956                    },
957                    keys: Vec::new(),
958                    action: "noop".to_string(),
959                    args: HashMap::new(),
960                    when: if binding.context.is_empty() {
961                        None
962                    } else {
963                        Some(binding.context.clone())
964                    },
965                };
966                self.pending_adds.push(noop_kb);
967
968                let noop_display = KeybindingResolver::format_action_from_str("noop");
969                self.bindings[idx] = ResolvedBinding {
970                    key_display: self.bindings[idx].key_display.clone(),
971                    action: "noop".to_string(),
972                    action_display: noop_display,
973                    context: self.bindings[idx].context.clone(),
974                    source: BindingSource::Custom,
975                    key_code: self.bindings[idx].key_code,
976                    modifiers: self.bindings[idx].modifiers,
977                    is_chord: self.bindings[idx].is_chord,
978                    plugin_name: self.bindings[idx].plugin_name.clone(),
979                    command_name: None,
980                    original_config: None,
981                };
982                self.has_changes = true;
983
984                let still_bound = self.bindings.iter().any(|b| b.action == action_name);
985                if !still_bound {
986                    let action_display = KeybindingResolver::format_action_from_str(&action_name);
987                    self.bindings.push(ResolvedBinding {
988                        key_display: String::new(),
989                        action: action_name,
990                        action_display,
991                        context: String::new(),
992                        source: BindingSource::Unbound,
993                        key_code: KeyCode::Null,
994                        modifiers: KeyModifiers::NONE,
995                        is_chord: false,
996                        plugin_name: None,
997                        command_name: None,
998                        original_config: None,
999                    });
1000                }
1001
1002                self.apply_filters();
1003                DeleteResult::KeymapOverridden
1004            }
1005            BindingSource::Unbound => DeleteResult::CannotDelete,
1006        }
1007    }
1008
1009    /// Convert a ResolvedBinding to a config-level Keybinding (for matching).
1010    fn resolved_to_config_keybinding(&self, binding: &ResolvedBinding) -> Keybinding {
1011        let (action, args) = Action::unqualify_action(&binding.action);
1012        Keybinding {
1013            key: if binding.is_chord {
1014                String::new()
1015            } else {
1016                key_code_to_config_name(binding.key_code)
1017            },
1018            modifiers: if binding.is_chord {
1019                Vec::new()
1020            } else {
1021                modifiers_to_config_names(binding.modifiers)
1022            },
1023            keys: Vec::new(),
1024            action,
1025            args,
1026            when: if binding.context.is_empty() {
1027                None
1028            } else {
1029                Some(binding.context.clone())
1030            },
1031        }
1032    }
1033
1034    /// Apply the edit dialog to create/update a binding.
1035    /// Returns an error message if validation fails.
1036    pub fn apply_edit_dialog(&mut self) -> Option<String> {
1037        let dialog = self.edit_dialog.take()?;
1038
1039        if dialog.key_code.is_none() || dialog.action_text.is_empty() {
1040            self.edit_dialog = Some(dialog);
1041            return Some(t!("keybinding_editor.error_key_action_required").to_string());
1042        }
1043
1044        // Validate the action name
1045        if !self.is_valid_action(&dialog.action_text) {
1046            let err_msg = t!(
1047                "keybinding_editor.error_unknown_action",
1048                action = &dialog.action_text
1049            )
1050            .to_string();
1051            let mut dialog = dialog;
1052            dialog.action_error = Some(
1053                t!(
1054                    "keybinding_editor.error_unknown_action_short",
1055                    action = &dialog.action_text
1056                )
1057                .to_string(),
1058            );
1059            self.edit_dialog = Some(dialog);
1060            return Some(err_msg);
1061        }
1062
1063        let key_code = dialog.key_code.unwrap();
1064        let modifiers = dialog.modifiers;
1065        let key_name = key_code_to_config_name(key_code);
1066        let modifier_names = modifiers_to_config_names(modifiers);
1067
1068        // Split the qualified form (e.g. `menu_open:File`) into bare action +
1069        // args so the written Keybinding actually parses back to the right
1070        // variant at runtime.
1071        let (bare_action, args) = Action::unqualify_action(&dialog.action_text);
1072
1073        let new_binding = Keybinding {
1074            key: key_name,
1075            modifiers: modifier_names,
1076            keys: Vec::new(),
1077            action: bare_action.clone(),
1078            args: args.clone(),
1079            when: Some(dialog.context.clone()),
1080        };
1081
1082        // Add as custom binding
1083        self.pending_adds.push(new_binding.clone());
1084        self.has_changes = true;
1085
1086        // Update display
1087        let key_display = format_keybinding(&key_code, &modifiers);
1088        let action_display =
1089            KeybindingResolver::format_action_from_str_with_args(&bare_action, &args);
1090
1091        // When editing an existing binding, preserve its plugin_name so it stays
1092        // in the same section. New bindings go to Builtin (plugin_name: None).
1093        let preserved_plugin_name = dialog
1094            .editing_index
1095            .and_then(|idx| self.bindings.get(idx))
1096            .and_then(|b| b.plugin_name.clone());
1097
1098        let resolved = ResolvedBinding {
1099            key_display,
1100            action: dialog.action_text,
1101            action_display,
1102            context: dialog.context,
1103            source: BindingSource::Custom,
1104            key_code,
1105            modifiers,
1106            is_chord: false,
1107            plugin_name: preserved_plugin_name,
1108            command_name: None,
1109            original_config: None,
1110        };
1111
1112        if let Some(edit_idx) = dialog.editing_index {
1113            // Editing existing - replace it
1114            if edit_idx < self.bindings.len() {
1115                self.bindings[edit_idx] = resolved;
1116            }
1117        } else {
1118            // Adding new
1119            self.bindings.push(resolved);
1120        }
1121
1122        self.apply_filters();
1123        None
1124    }
1125
1126    /// Check for conflicts with the given key combination
1127    pub fn find_conflicts(
1128        &self,
1129        key_code: KeyCode,
1130        modifiers: KeyModifiers,
1131        context: &str,
1132    ) -> Vec<String> {
1133        let mut conflicts = Vec::new();
1134
1135        for binding in &self.bindings {
1136            if !binding.is_chord
1137                && binding.key_code == key_code
1138                && binding.modifiers == modifiers
1139                && (binding.context == context
1140                    || binding.context == "global"
1141                    || context == "global")
1142            {
1143                conflicts.push(format!(
1144                    "{} ({}, {})",
1145                    binding.action_display,
1146                    binding.context,
1147                    match binding.source {
1148                        BindingSource::Custom => "custom",
1149                        BindingSource::Plugin => "plugin",
1150                        _ => "keymap",
1151                    }
1152                ));
1153            }
1154        }
1155
1156        conflicts
1157    }
1158
1159    /// Get the custom bindings to save to config
1160    pub fn get_custom_bindings(&self) -> Vec<Keybinding> {
1161        self.pending_adds.clone()
1162    }
1163
1164    /// Get the custom bindings to remove from config
1165    pub fn get_pending_removes(&self) -> &[Keybinding] {
1166        &self.pending_removes
1167    }
1168
1169    /// Get the context filter display string
1170    pub fn context_filter_display(&self) -> &str {
1171        match &self.context_filter {
1172            ContextFilter::All => "All",
1173            ContextFilter::Specific(ctx) => ctx.as_str(),
1174        }
1175    }
1176
1177    /// Get the source filter display string
1178    pub fn source_filter_display(&self) -> &str {
1179        match &self.source_filter {
1180            SourceFilter::All => "All",
1181            SourceFilter::KeymapOnly => "Keymap",
1182            SourceFilter::CustomOnly => "Custom",
1183            SourceFilter::PluginOnly => "Plugin",
1184        }
1185    }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use crate::input::buffer_mode::ModeRegistry;
1192
1193    fn make_editor(extra_menus: &[&str]) -> KeybindingEditor {
1194        let config = Config::default();
1195        let resolver = KeybindingResolver::new(&config);
1196        let mode_registry = ModeRegistry::new();
1197        let cmd_registry = CommandRegistry::new();
1198        let mut menu_names: Vec<String> = ["File", "Edit", "View"]
1199            .iter()
1200            .map(|s| s.to_string())
1201            .collect();
1202        menu_names.extend(extra_menus.iter().map(|s| s.to_string()));
1203        KeybindingEditor::new(
1204            &config,
1205            &resolver,
1206            &mode_registry,
1207            &cmd_registry,
1208            String::from("/tmp/fresh-config.toml"),
1209            &menu_names,
1210        )
1211    }
1212
1213    #[test]
1214    fn dropdown_lists_menu_open_variants_not_bare_entry() {
1215        // Regression for #1407 follow-up: picking `menu_open` from the
1216        // dropdown used to produce a no-op binding because no args were
1217        // attached. The dropdown should instead offer one entry per menu.
1218        let editor = make_editor(&[]);
1219        assert!(
1220            !editor.available_actions.iter().any(|a| a == "menu_open"),
1221            "bare `menu_open` must not appear — it is un-parseable without args"
1222        );
1223        assert!(
1224            editor
1225                .available_actions
1226                .contains(&"menu_open:File".to_string()),
1227            "expected dropdown to list `menu_open:File`, got {:?}",
1228            editor.available_actions
1229        );
1230        assert!(
1231            editor
1232                .available_actions
1233                .contains(&"menu_open:Edit".to_string()),
1234            "expected dropdown to list `menu_open:Edit`"
1235        );
1236    }
1237
1238    #[test]
1239    fn dropdown_includes_plugin_menus_passed_in() {
1240        let editor = make_editor(&["MyPluginMenu"]);
1241        assert!(
1242            editor
1243                .available_actions
1244                .contains(&"menu_open:MyPluginMenu".to_string()),
1245            "plugin menus should surface as dropdown entries"
1246        );
1247    }
1248
1249    #[test]
1250    fn dropdown_lists_builtin_keybinding_maps() {
1251        let editor = make_editor(&[]);
1252        for map in ["default", "emacs", "vscode", "macos"] {
1253            let qualified = format!("switch_keybinding_map:{}", map);
1254            assert!(
1255                editor.available_actions.contains(&qualified),
1256                "expected `{}` in dropdown",
1257                qualified
1258            );
1259        }
1260        assert!(
1261            !editor
1262                .available_actions
1263                .iter()
1264                .any(|a| a == "switch_keybinding_map"),
1265            "bare `switch_keybinding_map` must not appear"
1266        );
1267    }
1268
1269    #[test]
1270    fn qualified_action_roundtrips_through_resolved_to_config() {
1271        // A binding selected from the dropdown as `menu_open:File` must be
1272        // written to config as `{action: "menu_open", args: {name: "File"}}`.
1273        let editor = make_editor(&[]);
1274        let rb = ResolvedBinding {
1275            key_display: "Alt+F".to_string(),
1276            action: "menu_open:File".to_string(),
1277            action_display: String::new(),
1278            context: "global".to_string(),
1279            source: BindingSource::Custom,
1280            key_code: KeyCode::Char('f'),
1281            modifiers: KeyModifiers::ALT,
1282            is_chord: false,
1283            plugin_name: None,
1284            command_name: None,
1285            original_config: None,
1286        };
1287        let kb = editor.resolved_to_config_keybinding(&rb);
1288        assert_eq!(kb.action, "menu_open");
1289        assert_eq!(
1290            kb.args.get("name").and_then(|v| v.as_str()),
1291            Some("File"),
1292            "the variant name must land in args.name, got {:?}",
1293            kb.args
1294        );
1295    }
1296}