1use 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#[derive(Debug)]
16pub struct KeybindingEditor {
17 pub bindings: Vec<ResolvedBinding>,
19 pub filtered_indices: Vec<usize>,
21 pub selected: usize,
23 pub scroll: crate::view::ui::ScrollState,
25
26 pub search_active: bool,
28 pub search_focused: bool,
30 pub search_query: String,
32 pub search_mode: SearchMode,
34 pub search_key_display: String,
36 pub search_key_code: Option<KeyCode>,
38 pub search_modifiers: KeyModifiers,
40
41 pub context_filter: ContextFilter,
43 pub source_filter: SourceFilter,
45
46 pub edit_dialog: Option<EditBindingState>,
48
49 pub showing_help: bool,
51
52 pub active_keymap: String,
54 pub config_file_path: String,
56
57 pub pending_adds: Vec<Keybinding>,
59 pub pending_removes: Vec<Keybinding>,
61 pub has_changes: bool,
63
64 pub showing_confirm_dialog: bool,
66 pub confirm_selection: usize,
68
69 pub keymap_names: Vec<String>,
71
72 pub available_actions: Vec<String>,
74
75 pub mode_contexts: Vec<String>,
77
78 pub display_rows: Vec<DisplayRow>,
80 pub collapsed_sections: HashSet<Option<String>>,
82
83 pub layout: KeybindingEditorLayout,
85
86 pub scrollbar_mouse: crate::view::ui::scrollbar::ScrollbarMouse,
88}
89
90impl KeybindingEditor {
91 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 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 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 Self::expand_variant_actions(&mut available_actions, menu_names, config);
137
138 available_actions.sort();
139 available_actions.dedup();
140
141 let mut keymap_names: Vec<String> = config.keybinding_maps.keys().cloned().collect();
143 keymap_names.sort();
144
145 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 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 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(); 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 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 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 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 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 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 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 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 {
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 binding.action_display = name.clone();
344 binding.command_name = Some(name);
345 }
346 }
347 }
348 }
349
350 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 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 let qualified_action = Action::qualify_action(&kb.action, &kb.args);
373
374 if !kb.keys.is_empty() {
375 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 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 fn collect_action_names() -> Vec<String> {
429 Action::all_action_names()
430 }
431
432 fn expand_variant_actions(actions: &mut Vec<String>, menu_names: &[String], config: &Config) {
439 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 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 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 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 dialog.autocomplete_selected = if dialog.autocomplete_visible {
494 Some(0)
495 } else {
496 None
497 };
498 dialog.action_error = None;
500 }
501 }
502
503 pub fn is_valid_action(&self, action_name: &str) -> bool {
505 self.available_actions.iter().any(|a| a == action_name)
506 }
507
508 pub fn apply_filters(&mut self) {
510 self.filtered_indices.clear();
511
512 for (i, binding) in self.bindings.iter().enumerate() {
513 if let ContextFilter::Specific(ref ctx) = self.context_filter {
515 if &binding.context != ctx {
516 continue;
517 }
518 }
519
520 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 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; }
558 }
559 }
560 }
561 }
562
563 self.filtered_indices.push(i);
564 }
565
566 self.build_display_rows();
568
569 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 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 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 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 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 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 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 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 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 pub fn select_prev(&mut self) {
670 if self.selected > 0 {
671 self.selected -= 1;
672 self.ensure_visible();
673 }
674 }
675
676 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 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 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 pub fn ensure_visible_public(&mut self) {
704 self.ensure_visible();
705 }
706
707 fn ensure_visible(&mut self) {
709 self.scroll.ensure_visible(self.selected as u16, 1);
710 }
711
712 pub fn start_search(&mut self) {
714 if !self.search_active || self.search_mode != SearchMode::Text {
715 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 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 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 pub fn record_search_key(&mut self, event: &KeyEvent) {
747 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 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 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 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 pub fn open_add_dialog(&mut self) {
797 self.edit_dialog = Some(EditBindingState::new_add_with_modes(&self.mode_contexts));
798 }
799
800 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 pub fn close_edit_dialog(&mut self) {
814 self.edit_dialog = None;
815 }
816
817 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 BindingSource::Keymap | BindingSource::Plugin => self.override_binding_with_noop(idx),
837 BindingSource::Unbound => DeleteResult::CannotDelete,
838 }
839 }
840
841 fn delete_custom_binding(&mut self, idx: usize) -> DeleteResult {
845 let binding = &self.bindings[idx];
846 let action_name = binding.action.clone();
847
848 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 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 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 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 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 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 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 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 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 self.pending_adds.push(new_binding.clone());
1027 self.has_changes = true;
1028
1029 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 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 if edit_idx < self.bindings.len() {
1058 self.bindings[edit_idx] = resolved;
1059 }
1060 } else {
1061 self.bindings.push(resolved);
1063 }
1064
1065 self.apply_filters();
1066 None
1067 }
1068
1069 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 pub fn get_custom_bindings(&self) -> Vec<Keybinding> {
1104 self.pending_adds.clone()
1105 }
1106
1107 pub fn get_pending_removes(&self) -> &[Keybinding] {
1109 &self.pending_removes
1110 }
1111
1112 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 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 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 let mut editor = make_editor(&[]);
1220 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 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}