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