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