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