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::{format_keybinding, Action, KeyContext, KeybindingResolver};
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9use rust_i18n::t;
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug)]
14pub struct KeybindingEditor {
15 pub bindings: Vec<ResolvedBinding>,
17 pub filtered_indices: Vec<usize>,
19 pub selected: usize,
21 pub scroll: crate::view::ui::ScrollState,
23
24 pub search_active: bool,
26 pub search_focused: bool,
28 pub search_query: String,
30 pub search_mode: SearchMode,
32 pub search_key_display: String,
34 pub search_key_code: Option<KeyCode>,
36 pub search_modifiers: KeyModifiers,
38
39 pub context_filter: ContextFilter,
41 pub source_filter: SourceFilter,
43
44 pub edit_dialog: Option<EditBindingState>,
46
47 pub showing_help: bool,
49
50 pub active_keymap: String,
52 pub config_file_path: String,
54
55 pub pending_adds: Vec<Keybinding>,
57 pub pending_removes: Vec<Keybinding>,
59 pub has_changes: bool,
61
62 pub showing_confirm_dialog: bool,
64 pub confirm_selection: usize,
66
67 pub keymap_names: Vec<String>,
69
70 pub available_actions: Vec<String>,
72
73 pub mode_contexts: Vec<String>,
75
76 pub display_rows: Vec<DisplayRow>,
78 pub collapsed_sections: HashSet<Option<String>>,
80
81 pub layout: KeybindingEditorLayout,
83
84 pub scrollbar_mouse: crate::view::ui::scrollbar::ScrollbarMouse,
86}
87
88impl KeybindingEditor {
89 pub fn new(
96 config: &Config,
97 resolver: &KeybindingResolver,
98 mode_registry: &crate::input::buffer_mode::ModeRegistry,
99 command_registry: &CommandRegistry,
100 config_file_path: String,
101 menu_names: &[String],
102 ) -> Self {
103 let bindings =
104 Self::resolve_all_bindings(config, resolver, mode_registry, command_registry);
105 let filtered_indices: Vec<usize> = (0..bindings.len()).collect();
106
107 let mut available_actions = Self::collect_action_names();
109 for mode_bindings in resolver.get_plugin_defaults().values() {
110 for action in mode_bindings.values() {
111 let action_name = format!("{:?}", action);
112 let action_str = match action {
113 Action::PluginAction(name) => name.clone(),
114 other => format!("{:?}", other),
115 };
116 if !available_actions.contains(&action_str) {
117 available_actions.push(action_str);
118 }
119 let _ = action_name;
120 }
121 }
122 for cmd in command_registry.get_all() {
124 if let Action::PluginAction(ref name) = cmd.action {
125 if !available_actions.contains(name) {
126 available_actions.push(name.clone());
127 }
128 }
129 }
130
131 Self::expand_variant_actions(&mut available_actions, menu_names, config);
135
136 available_actions.sort();
137 available_actions.dedup();
138
139 let mut keymap_names: Vec<String> = config.keybinding_maps.keys().cloned().collect();
141 keymap_names.sort();
142
143 let mut mode_contexts: Vec<String> = resolver
145 .get_plugin_defaults()
146 .keys()
147 .filter_map(|ctx| {
148 if let KeyContext::Mode(name) = ctx {
149 Some(format!("mode:{}", name))
150 } else {
151 None
152 }
153 })
154 .collect();
155 mode_contexts.sort();
156
157 let mut collapsed_sections: HashSet<Option<String>> = HashSet::new();
159 for b in &bindings {
160 if b.plugin_name.is_some() {
161 collapsed_sections.insert(b.plugin_name.clone());
162 }
163 }
164
165 let mut editor = Self {
166 bindings,
167 filtered_indices,
168 selected: 0,
169 scroll: crate::view::ui::ScrollState::default(),
170 search_active: false,
171 search_focused: false,
172 search_query: String::new(),
173 search_mode: SearchMode::Text,
174 search_key_display: String::new(),
175 search_key_code: None,
176 search_modifiers: KeyModifiers::NONE,
177 context_filter: ContextFilter::All,
178 source_filter: SourceFilter::All,
179 edit_dialog: None,
180 showing_help: false,
181 active_keymap: config.active_keybinding_map.to_string(),
182 config_file_path,
183 pending_adds: Vec::new(),
184 pending_removes: Vec::new(),
185 has_changes: false,
186 showing_confirm_dialog: false,
187 confirm_selection: 0,
188 keymap_names,
189 available_actions,
190 mode_contexts,
191 display_rows: Vec::new(),
192 collapsed_sections,
193 layout: KeybindingEditorLayout::default(),
194 scrollbar_mouse: crate::view::ui::scrollbar::ScrollbarMouse::default(),
195 };
196
197 editor.apply_filters();
198 editor
199 }
200
201 fn resolve_all_bindings(
203 config: &Config,
204 resolver: &KeybindingResolver,
205 mode_registry: &crate::input::buffer_mode::ModeRegistry,
206 command_registry: &CommandRegistry,
207 ) -> Vec<ResolvedBinding> {
208 let mut bindings = Vec::new();
209 let mut seen: HashMap<(String, String), usize> = HashMap::new(); let map_bindings = config.resolve_keymap(&config.active_keybinding_map);
213 for kb in &map_bindings {
214 if let Some(entry) = Self::keybinding_to_resolved(kb, BindingSource::Keymap, resolver) {
215 let key = (entry.key_display.clone(), entry.context.clone());
216 let idx = bindings.len();
217 seen.insert(key, idx);
218 bindings.push(entry);
219 }
220 }
221
222 for kb in &config.keybindings {
224 if let Some(entry) = Self::keybinding_to_resolved(kb, BindingSource::Custom, resolver) {
225 let key = (entry.key_display.clone(), entry.context.clone());
226 if let Some(&existing_idx) = seen.get(&key) {
227 bindings[existing_idx] = entry;
229 } else {
230 let idx = bindings.len();
231 seen.insert(key, idx);
232 bindings.push(entry);
233 }
234 }
235 }
236
237 for (context, context_bindings) in resolver.get_plugin_defaults() {
239 if let KeyContext::Mode(mode_name) = context {
240 let context_str = format!("mode:{}", mode_name);
241 let section = mode_registry
243 .get(mode_name)
244 .and_then(|m| m.plugin_name.clone())
245 .unwrap_or_else(|| mode_name.clone());
246 for ((key_code, modifiers), action) in context_bindings {
247 let key_display = format_keybinding(key_code, modifiers);
248 let seen_key = (key_display.clone(), context_str.clone());
249 if seen.contains_key(&seen_key) {
251 continue;
252 }
253 let command = action.to_qualified_action_str();
254 let action_display = KeybindingResolver::format_action(action);
255 let idx = bindings.len();
256 seen.insert(seen_key, idx);
257 bindings.push(ResolvedBinding {
258 key_display,
259 action: command,
260 action_display,
261 context: context_str.clone(),
262 source: BindingSource::Plugin,
263 key_code: *key_code,
264 modifiers: *modifiers,
265 is_chord: false,
266 plugin_name: Some(section.clone()),
267 command_name: None,
268 original_config: None,
269 });
270 }
271 }
272 }
273
274 let bound_actions: std::collections::HashSet<String> =
276 bindings.iter().map(|b| b.action.clone()).collect();
277 for action_name in Action::all_action_names() {
278 if !bound_actions.contains(&action_name) {
279 let action_display = KeybindingResolver::format_action_from_str(&action_name);
280 bindings.push(ResolvedBinding {
281 key_display: String::new(),
282 action: action_name,
283 action_display,
284 context: String::new(),
285 source: BindingSource::Unbound,
286 key_code: KeyCode::Null,
287 modifiers: KeyModifiers::NONE,
288 is_chord: false,
289 plugin_name: None,
290 command_name: None,
291 original_config: None,
292 });
293 }
294 }
295
296 for cmd in command_registry.get_all() {
298 if let Action::PluginAction(ref action_name) = cmd.action {
299 if !bound_actions.contains(action_name) {
300 let plugin_name = match &cmd.source {
301 crate::input::commands::CommandSource::Plugin(name) => Some(name.clone()),
302 _ => None,
303 };
304 bindings.push(ResolvedBinding {
305 key_display: String::new(),
306 action: action_name.clone(),
307 action_display: cmd.get_localized_name(),
308 context: String::new(),
309 source: BindingSource::Unbound,
310 key_code: KeyCode::Null,
311 modifiers: KeyModifiers::NONE,
312 is_chord: false,
313 plugin_name,
314 command_name: Some(cmd.get_localized_name()),
315 original_config: None,
316 });
317 }
318 }
319 }
320
321 {
323 let commands = command_registry.get_all();
324 let cmd_by_action: std::collections::HashMap<&str, &crate::input::commands::Command> =
325 commands
326 .iter()
327 .filter_map(|c| {
328 if let Action::PluginAction(ref name) = c.action {
329 Some((name.as_str(), c))
330 } else {
331 None
332 }
333 })
334 .collect();
335 for binding in &mut bindings {
336 if binding.command_name.is_none() {
337 if let Some(cmd) = cmd_by_action.get(binding.action.as_str()) {
338 let name = cmd.get_localized_name();
339 binding.action_display = name.clone();
342 binding.command_name = Some(name);
343 }
344 }
345 }
346 }
347
348 bindings.sort_by(|a, b| {
350 a.plugin_name
351 .cmp(&b.plugin_name)
352 .then(a.context.cmp(&b.context))
353 .then(a.action_display.cmp(&b.action_display))
354 });
355
356 bindings
357 }
358
359 fn keybinding_to_resolved(
361 kb: &Keybinding,
362 source: BindingSource,
363 _resolver: &KeybindingResolver,
364 ) -> Option<ResolvedBinding> {
365 let context = kb.when.as_deref().unwrap_or("normal").to_string();
366
367 let qualified_action = Action::qualify_action(&kb.action, &kb.args);
371
372 if !kb.keys.is_empty() {
373 let key_display = format_chord_keys(&kb.keys);
375 let action_display =
376 KeybindingResolver::format_action_from_str_with_args(&kb.action, &kb.args);
377 let original_config = if source == BindingSource::Custom {
378 Some(kb.clone())
379 } else {
380 None
381 };
382 Some(ResolvedBinding {
383 key_display,
384 action: qualified_action,
385 action_display,
386 context,
387 source,
388 key_code: KeyCode::Null,
389 modifiers: KeyModifiers::NONE,
390 is_chord: true,
391 plugin_name: None,
392 command_name: None,
393 original_config,
394 })
395 } else if !kb.key.is_empty() {
396 let key_code = KeybindingResolver::parse_key_public(&kb.key)?;
398 let modifiers = KeybindingResolver::parse_modifiers_public(&kb.modifiers);
399 let key_display = format_keybinding(&key_code, &modifiers);
400 let action_display =
401 KeybindingResolver::format_action_from_str_with_args(&kb.action, &kb.args);
402 let original_config = if source == BindingSource::Custom {
403 Some(kb.clone())
404 } else {
405 None
406 };
407 Some(ResolvedBinding {
408 key_display,
409 action: qualified_action,
410 action_display,
411 context,
412 source,
413 key_code,
414 modifiers,
415 is_chord: false,
416 plugin_name: None,
417 command_name: None,
418 original_config,
419 })
420 } else {
421 None
422 }
423 }
424
425 fn collect_action_names() -> Vec<String> {
427 Action::all_action_names()
428 }
429
430 fn expand_variant_actions(actions: &mut Vec<String>, menu_names: &[String], config: &Config) {
437 let mut menus: Vec<String> = menu_names.to_vec();
439 menus.sort();
440 menus.dedup();
441 actions.retain(|a| a != "menu_open");
442 for name in &menus {
443 actions.push(format!("menu_open:{}", name));
444 }
445
446 let mut keymaps: Vec<String> = ["default", "emacs", "vscode", "macos"]
448 .map(String::from)
449 .to_vec();
450 keymaps.extend(config.keybinding_maps.keys().cloned());
451 keymaps.sort();
452 keymaps.dedup();
453 actions.retain(|a| a != "switch_keybinding_map");
454 for map in &keymaps {
455 actions.push(format!("switch_keybinding_map:{}", map));
456 }
457 }
458
459 pub fn update_autocomplete(&mut self) {
461 if let Some(ref mut dialog) = self.edit_dialog {
462 let query = dialog.action_text.to_lowercase();
463 if query.is_empty() {
464 dialog.autocomplete_suggestions.clear();
465 dialog.autocomplete_visible = false;
466 dialog.autocomplete_selected = None;
467 return;
468 }
469
470 dialog.autocomplete_suggestions = self
471 .available_actions
472 .iter()
473 .filter(|a| a.to_lowercase().contains(&query))
474 .cloned()
475 .collect();
476
477 let q = query.clone();
479 dialog.autocomplete_suggestions.sort_by(|a, b| {
480 let a_prefix = a.to_lowercase().starts_with(&q);
481 let b_prefix = b.to_lowercase().starts_with(&q);
482 match (a_prefix, b_prefix) {
483 (true, false) => std::cmp::Ordering::Less,
484 (false, true) => std::cmp::Ordering::Greater,
485 _ => a.cmp(b),
486 }
487 });
488
489 dialog.autocomplete_visible = !dialog.autocomplete_suggestions.is_empty();
490 dialog.autocomplete_selected = if dialog.autocomplete_visible {
492 Some(0)
493 } else {
494 None
495 };
496 dialog.action_error = None;
498 }
499 }
500
501 pub fn is_valid_action(&self, action_name: &str) -> bool {
503 self.available_actions.iter().any(|a| a == action_name)
504 }
505
506 pub fn apply_filters(&mut self) {
508 self.filtered_indices.clear();
509
510 for (i, binding) in self.bindings.iter().enumerate() {
511 if let ContextFilter::Specific(ref ctx) = self.context_filter {
513 if &binding.context != ctx {
514 continue;
515 }
516 }
517
518 match self.source_filter {
520 SourceFilter::KeymapOnly if binding.source != BindingSource::Keymap => continue,
521 SourceFilter::CustomOnly if binding.source != BindingSource::Custom => continue,
522 SourceFilter::PluginOnly if binding.source != BindingSource::Plugin => continue,
523 _ => {}
524 }
525
526 if self.search_active {
528 match self.search_mode {
529 SearchMode::Text => {
530 if !self.search_query.is_empty() {
531 let query = self.search_query.to_lowercase();
532 let matches = binding.action.to_lowercase().contains(&query)
533 || binding.action_display.to_lowercase().contains(&query)
534 || binding.key_display.to_lowercase().contains(&query)
535 || binding.context.to_lowercase().contains(&query)
536 || binding
537 .command_name
538 .as_ref()
539 .is_some_and(|n| n.to_lowercase().contains(&query));
540 if !matches {
541 continue;
542 }
543 }
544 }
545 SearchMode::RecordKey => {
546 if let Some(search_key) = self.search_key_code {
547 if !binding.is_chord {
548 let key_matches = binding.key_code == search_key
549 && binding.modifiers == self.search_modifiers;
550 if !key_matches {
551 continue;
552 }
553 } else {
554 continue; }
556 }
557 }
558 }
559 }
560
561 self.filtered_indices.push(i);
562 }
563
564 self.build_display_rows();
566
567 if self.selected >= self.display_rows.len() {
569 self.selected = self.display_rows.len().saturating_sub(1);
570 }
571 self.ensure_visible();
572 }
573
574 fn build_display_rows(&mut self) {
576 self.display_rows.clear();
577
578 let has_active_filter = (self.search_active
579 && match self.search_mode {
580 SearchMode::Text => !self.search_query.is_empty(),
581 SearchMode::RecordKey => self.search_key_code.is_some(),
582 })
583 || !matches!(self.context_filter, ContextFilter::All)
584 || !matches!(self.source_filter, SourceFilter::All);
585
586 let mut sections: Vec<(Option<String>, Vec<usize>)> = Vec::new();
588 let mut current_section: Option<&Option<String>> = None;
589
590 for &idx in &self.filtered_indices {
591 let binding = &self.bindings[idx];
592 if current_section != Some(&binding.plugin_name) {
593 sections.push((binding.plugin_name.clone(), Vec::new()));
594 current_section = Some(&binding.plugin_name);
595 }
596 sections.last_mut().unwrap().1.push(idx);
597 }
598
599 for (plugin_name, indices) in sections {
600 let collapsed = if has_active_filter {
603 false
604 } else {
605 self.collapsed_sections.contains(&plugin_name)
606 };
607
608 self.display_rows.push(DisplayRow::SectionHeader {
609 plugin_name: plugin_name.clone(),
610 collapsed,
611 binding_count: indices.len(),
612 });
613
614 if !collapsed {
615 for idx in indices {
616 self.display_rows.push(DisplayRow::Binding(idx));
617 }
618 }
619 }
620 }
621
622 pub fn toggle_section_at_selected(&mut self) {
624 if let Some(DisplayRow::SectionHeader { plugin_name, .. }) =
625 self.display_rows.get(self.selected)
626 {
627 let key = plugin_name.clone();
628 if self.collapsed_sections.contains(&key) {
629 self.collapsed_sections.remove(&key);
630 } else {
631 self.collapsed_sections.insert(key);
632 }
633 self.build_display_rows();
634 if self.selected >= self.display_rows.len() {
636 self.selected = self.display_rows.len().saturating_sub(1);
637 }
638 self.ensure_visible();
639 }
640 }
641
642 pub fn selected_is_section_header(&self) -> bool {
644 matches!(
645 self.display_rows.get(self.selected),
646 Some(DisplayRow::SectionHeader { .. })
647 )
648 }
649
650 pub fn selected_binding(&self) -> Option<&ResolvedBinding> {
652 match self.display_rows.get(self.selected) {
653 Some(DisplayRow::Binding(idx)) => self.bindings.get(*idx),
654 _ => None,
655 }
656 }
657
658 fn selected_binding_index(&self) -> Option<usize> {
660 match self.display_rows.get(self.selected) {
661 Some(DisplayRow::Binding(idx)) => Some(*idx),
662 _ => None,
663 }
664 }
665
666 pub fn select_prev(&mut self) {
668 if self.selected > 0 {
669 self.selected -= 1;
670 self.ensure_visible();
671 }
672 }
673
674 pub fn select_next(&mut self) {
676 if self.selected + 1 < self.display_rows.len() {
677 self.selected += 1;
678 self.ensure_visible();
679 }
680 }
681
682 pub fn page_up(&mut self) {
684 let page = self.scroll.viewport as usize;
685 if self.selected > page {
686 self.selected -= page;
687 } else {
688 self.selected = 0;
689 }
690 self.ensure_visible();
691 }
692
693 pub fn page_down(&mut self) {
695 let page = self.scroll.viewport as usize;
696 self.selected = (self.selected + page).min(self.display_rows.len().saturating_sub(1));
697 self.ensure_visible();
698 }
699
700 pub fn ensure_visible_public(&mut self) {
702 self.ensure_visible();
703 }
704
705 fn ensure_visible(&mut self) {
707 self.scroll.ensure_visible(self.selected as u16, 1);
708 }
709
710 pub fn start_search(&mut self) {
712 if !self.search_active || self.search_mode != SearchMode::Text {
713 self.search_mode = SearchMode::Text;
715 if !self.search_active {
716 self.search_query.clear();
717 }
718 }
719 self.search_active = true;
720 self.search_focused = true;
721 }
722
723 pub fn start_record_key_search(&mut self) {
725 self.search_active = true;
726 self.search_focused = true;
727 self.search_mode = SearchMode::RecordKey;
728 self.search_key_display.clear();
729 self.search_key_code = None;
730 self.search_modifiers = KeyModifiers::NONE;
731 }
732
733 pub fn cancel_search(&mut self) {
735 self.search_active = false;
736 self.search_focused = false;
737 self.search_query.clear();
738 self.search_key_code = None;
739 self.search_key_display.clear();
740 self.apply_filters();
741 }
742
743 pub fn record_search_key(&mut self, event: &KeyEvent) {
745 self.search_key_code = Some(event.code);
746 self.search_modifiers = event.modifiers;
747 self.search_key_display = format_keybinding(&event.code, &event.modifiers);
748 self.apply_filters();
749 }
750
751 pub fn cycle_context_filter(&mut self) {
753 let mut contexts = vec![
754 ContextFilter::All,
755 ContextFilter::Specific("global".to_string()),
756 ContextFilter::Specific("normal".to_string()),
757 ContextFilter::Specific("prompt".to_string()),
758 ContextFilter::Specific("popup".to_string()),
759 ContextFilter::Specific("completion".to_string()),
760 ContextFilter::Specific("file_explorer".to_string()),
761 ContextFilter::Specific("menu".to_string()),
762 ContextFilter::Specific("terminal".to_string()),
763 ];
764 for mode_ctx in &self.mode_contexts {
766 contexts.push(ContextFilter::Specific(mode_ctx.clone()));
767 }
768
769 let current_idx = contexts
770 .iter()
771 .position(|c| c == &self.context_filter)
772 .unwrap_or(0);
773 let next_idx = (current_idx + 1) % contexts.len();
774 self.context_filter = contexts.into_iter().nth(next_idx).unwrap();
775 self.apply_filters();
776 }
777
778 pub fn cycle_source_filter(&mut self) {
780 self.source_filter = match self.source_filter {
781 SourceFilter::All => SourceFilter::CustomOnly,
782 SourceFilter::CustomOnly => SourceFilter::KeymapOnly,
783 SourceFilter::KeymapOnly => SourceFilter::PluginOnly,
784 SourceFilter::PluginOnly => SourceFilter::All,
785 };
786 self.apply_filters();
787 }
788
789 pub fn open_add_dialog(&mut self) {
791 self.edit_dialog = Some(EditBindingState::new_add_with_modes(&self.mode_contexts));
792 }
793
794 pub fn open_edit_dialog(&mut self) {
796 if let Some(idx) = self.selected_binding_index() {
797 let binding = self.bindings[idx].clone();
798 self.edit_dialog = Some(EditBindingState::new_edit_with_modes(
799 idx,
800 &binding,
801 &self.mode_contexts,
802 ));
803 }
804 }
805
806 pub fn close_edit_dialog(&mut self) {
808 self.edit_dialog = None;
809 }
810
811 pub fn delete_selected(&mut self) -> DeleteResult {
821 let Some(idx) = self.selected_binding_index() else {
822 return DeleteResult::NothingSelected;
823 };
824
825 match self.bindings[idx].source {
826 BindingSource::Custom => {
827 let binding = &self.bindings[idx];
828 let action_name = binding.action.clone();
829
830 let config_kb = binding
835 .original_config
836 .clone()
837 .unwrap_or_else(|| self.resolved_to_config_keybinding(binding));
838
839 let found_in_adds = self.pending_adds.iter().position(|kb| {
843 kb.action == config_kb.action
844 && kb.key == config_kb.key
845 && kb.modifiers == config_kb.modifiers
846 && kb.when == config_kb.when
847 });
848 if let Some(pos) = found_in_adds {
849 self.pending_adds.remove(pos);
850 } else {
851 self.pending_removes.push(config_kb);
852 }
853
854 self.bindings.remove(idx);
855 self.has_changes = true;
856
857 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
859 if !still_bound {
860 let action_display = KeybindingResolver::format_action_from_str(&action_name);
861 self.bindings.push(ResolvedBinding {
862 key_display: String::new(),
863 action: action_name,
864 action_display,
865 context: String::new(),
866 source: BindingSource::Unbound,
867 key_code: KeyCode::Null,
868 modifiers: KeyModifiers::NONE,
869 is_chord: false,
870 plugin_name: None,
871 command_name: None,
872 original_config: None,
873 });
874 }
875
876 self.apply_filters();
877 DeleteResult::CustomRemoved
878 }
879 BindingSource::Keymap => {
880 let binding = &self.bindings[idx];
881 let action_name = binding.action.clone();
882
883 let noop_kb = Keybinding {
885 key: if binding.is_chord {
886 String::new()
887 } else {
888 key_code_to_config_name(binding.key_code)
889 },
890 modifiers: if binding.is_chord {
891 Vec::new()
892 } else {
893 modifiers_to_config_names(binding.modifiers)
894 },
895 keys: Vec::new(),
896 action: "noop".to_string(),
897 args: HashMap::new(),
898 when: if binding.context.is_empty() {
899 None
900 } else {
901 Some(binding.context.clone())
902 },
903 };
904 self.pending_adds.push(noop_kb);
905
906 let noop_display = KeybindingResolver::format_action_from_str("noop");
908 self.bindings[idx] = ResolvedBinding {
909 key_display: self.bindings[idx].key_display.clone(),
910 action: "noop".to_string(),
911 action_display: noop_display,
912 context: self.bindings[idx].context.clone(),
913 source: BindingSource::Custom,
914 key_code: self.bindings[idx].key_code,
915 modifiers: self.bindings[idx].modifiers,
916 is_chord: self.bindings[idx].is_chord,
917 plugin_name: self.bindings[idx].plugin_name.clone(),
918 command_name: None,
919 original_config: None,
920 };
921 self.has_changes = true;
922
923 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
925 if !still_bound {
926 let action_display = KeybindingResolver::format_action_from_str(&action_name);
927 self.bindings.push(ResolvedBinding {
928 key_display: String::new(),
929 action: action_name,
930 action_display,
931 context: String::new(),
932 source: BindingSource::Unbound,
933 key_code: KeyCode::Null,
934 modifiers: KeyModifiers::NONE,
935 is_chord: false,
936 plugin_name: None,
937 command_name: None,
938 original_config: None,
939 });
940 }
941
942 self.apply_filters();
943 DeleteResult::KeymapOverridden
944 }
945 BindingSource::Plugin => {
946 let binding = &self.bindings[idx];
948 let action_name = binding.action.clone();
949
950 let noop_kb = Keybinding {
951 key: if binding.is_chord {
952 String::new()
953 } else {
954 key_code_to_config_name(binding.key_code)
955 },
956 modifiers: if binding.is_chord {
957 Vec::new()
958 } else {
959 modifiers_to_config_names(binding.modifiers)
960 },
961 keys: Vec::new(),
962 action: "noop".to_string(),
963 args: HashMap::new(),
964 when: if binding.context.is_empty() {
965 None
966 } else {
967 Some(binding.context.clone())
968 },
969 };
970 self.pending_adds.push(noop_kb);
971
972 let noop_display = KeybindingResolver::format_action_from_str("noop");
973 self.bindings[idx] = ResolvedBinding {
974 key_display: self.bindings[idx].key_display.clone(),
975 action: "noop".to_string(),
976 action_display: noop_display,
977 context: self.bindings[idx].context.clone(),
978 source: BindingSource::Custom,
979 key_code: self.bindings[idx].key_code,
980 modifiers: self.bindings[idx].modifiers,
981 is_chord: self.bindings[idx].is_chord,
982 plugin_name: self.bindings[idx].plugin_name.clone(),
983 command_name: None,
984 original_config: None,
985 };
986 self.has_changes = true;
987
988 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
989 if !still_bound {
990 let action_display = KeybindingResolver::format_action_from_str(&action_name);
991 self.bindings.push(ResolvedBinding {
992 key_display: String::new(),
993 action: action_name,
994 action_display,
995 context: String::new(),
996 source: BindingSource::Unbound,
997 key_code: KeyCode::Null,
998 modifiers: KeyModifiers::NONE,
999 is_chord: false,
1000 plugin_name: None,
1001 command_name: None,
1002 original_config: None,
1003 });
1004 }
1005
1006 self.apply_filters();
1007 DeleteResult::KeymapOverridden
1008 }
1009 BindingSource::Unbound => DeleteResult::CannotDelete,
1010 }
1011 }
1012
1013 fn resolved_to_config_keybinding(&self, binding: &ResolvedBinding) -> Keybinding {
1015 let (action, args) = Action::unqualify_action(&binding.action);
1016 Keybinding {
1017 key: if binding.is_chord {
1018 String::new()
1019 } else {
1020 key_code_to_config_name(binding.key_code)
1021 },
1022 modifiers: if binding.is_chord {
1023 Vec::new()
1024 } else {
1025 modifiers_to_config_names(binding.modifiers)
1026 },
1027 keys: Vec::new(),
1028 action,
1029 args,
1030 when: if binding.context.is_empty() {
1031 None
1032 } else {
1033 Some(binding.context.clone())
1034 },
1035 }
1036 }
1037
1038 pub fn apply_edit_dialog(&mut self) -> Option<String> {
1041 let dialog = self.edit_dialog.take()?;
1042
1043 if dialog.key_code.is_none() || dialog.action_text.is_empty() {
1044 self.edit_dialog = Some(dialog);
1045 return Some(t!("keybinding_editor.error_key_action_required").to_string());
1046 }
1047
1048 if !self.is_valid_action(&dialog.action_text) {
1050 let err_msg = t!(
1051 "keybinding_editor.error_unknown_action",
1052 action = &dialog.action_text
1053 )
1054 .to_string();
1055 let mut dialog = dialog;
1056 dialog.action_error = Some(
1057 t!(
1058 "keybinding_editor.error_unknown_action_short",
1059 action = &dialog.action_text
1060 )
1061 .to_string(),
1062 );
1063 self.edit_dialog = Some(dialog);
1064 return Some(err_msg);
1065 }
1066
1067 let key_code = dialog.key_code.unwrap();
1068 let modifiers = dialog.modifiers;
1069 let key_name = key_code_to_config_name(key_code);
1070 let modifier_names = modifiers_to_config_names(modifiers);
1071
1072 let (bare_action, args) = Action::unqualify_action(&dialog.action_text);
1076
1077 let new_binding = Keybinding {
1078 key: key_name,
1079 modifiers: modifier_names,
1080 keys: Vec::new(),
1081 action: bare_action.clone(),
1082 args: args.clone(),
1083 when: Some(dialog.context.clone()),
1084 };
1085
1086 self.pending_adds.push(new_binding.clone());
1088 self.has_changes = true;
1089
1090 let key_display = format_keybinding(&key_code, &modifiers);
1092 let action_display =
1093 KeybindingResolver::format_action_from_str_with_args(&bare_action, &args);
1094
1095 let preserved_plugin_name = dialog
1098 .editing_index
1099 .and_then(|idx| self.bindings.get(idx))
1100 .and_then(|b| b.plugin_name.clone());
1101
1102 let resolved = ResolvedBinding {
1103 key_display,
1104 action: dialog.action_text,
1105 action_display,
1106 context: dialog.context,
1107 source: BindingSource::Custom,
1108 key_code,
1109 modifiers,
1110 is_chord: false,
1111 plugin_name: preserved_plugin_name,
1112 command_name: None,
1113 original_config: None,
1114 };
1115
1116 if let Some(edit_idx) = dialog.editing_index {
1117 if edit_idx < self.bindings.len() {
1119 self.bindings[edit_idx] = resolved;
1120 }
1121 } else {
1122 self.bindings.push(resolved);
1124 }
1125
1126 self.apply_filters();
1127 None
1128 }
1129
1130 pub fn find_conflicts(
1132 &self,
1133 key_code: KeyCode,
1134 modifiers: KeyModifiers,
1135 context: &str,
1136 ) -> Vec<String> {
1137 let mut conflicts = Vec::new();
1138
1139 for binding in &self.bindings {
1140 if !binding.is_chord
1141 && binding.key_code == key_code
1142 && binding.modifiers == modifiers
1143 && (binding.context == context
1144 || binding.context == "global"
1145 || context == "global")
1146 {
1147 conflicts.push(format!(
1148 "{} ({}, {})",
1149 binding.action_display,
1150 binding.context,
1151 match binding.source {
1152 BindingSource::Custom => "custom",
1153 BindingSource::Plugin => "plugin",
1154 _ => "keymap",
1155 }
1156 ));
1157 }
1158 }
1159
1160 conflicts
1161 }
1162
1163 pub fn get_custom_bindings(&self) -> Vec<Keybinding> {
1165 self.pending_adds.clone()
1166 }
1167
1168 pub fn get_pending_removes(&self) -> &[Keybinding] {
1170 &self.pending_removes
1171 }
1172
1173 pub fn context_filter_display(&self) -> &str {
1175 match &self.context_filter {
1176 ContextFilter::All => "All",
1177 ContextFilter::Specific(ctx) => ctx.as_str(),
1178 }
1179 }
1180
1181 pub fn source_filter_display(&self) -> &str {
1183 match &self.source_filter {
1184 SourceFilter::All => "All",
1185 SourceFilter::KeymapOnly => "Keymap",
1186 SourceFilter::CustomOnly => "Custom",
1187 SourceFilter::PluginOnly => "Plugin",
1188 }
1189 }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use super::*;
1195 use crate::input::buffer_mode::ModeRegistry;
1196
1197 fn make_editor(extra_menus: &[&str]) -> KeybindingEditor {
1198 let config = Config::default();
1199 let resolver = KeybindingResolver::new(&config);
1200 let mode_registry = ModeRegistry::new();
1201 let cmd_registry = CommandRegistry::new();
1202 let mut menu_names: Vec<String> = ["File", "Edit", "View"]
1203 .iter()
1204 .map(|s| s.to_string())
1205 .collect();
1206 menu_names.extend(extra_menus.iter().map(|s| s.to_string()));
1207 KeybindingEditor::new(
1208 &config,
1209 &resolver,
1210 &mode_registry,
1211 &cmd_registry,
1212 String::from("/tmp/fresh-config.toml"),
1213 &menu_names,
1214 )
1215 }
1216
1217 #[test]
1218 fn dropdown_lists_menu_open_variants_not_bare_entry() {
1219 let editor = make_editor(&[]);
1223 assert!(
1224 !editor.available_actions.iter().any(|a| a == "menu_open"),
1225 "bare `menu_open` must not appear — it is un-parseable without args"
1226 );
1227 assert!(
1228 editor
1229 .available_actions
1230 .contains(&"menu_open:File".to_string()),
1231 "expected dropdown to list `menu_open:File`, got {:?}",
1232 editor.available_actions
1233 );
1234 assert!(
1235 editor
1236 .available_actions
1237 .contains(&"menu_open:Edit".to_string()),
1238 "expected dropdown to list `menu_open:Edit`"
1239 );
1240 }
1241
1242 #[test]
1243 fn dropdown_includes_plugin_menus_passed_in() {
1244 let editor = make_editor(&["MyPluginMenu"]);
1245 assert!(
1246 editor
1247 .available_actions
1248 .contains(&"menu_open:MyPluginMenu".to_string()),
1249 "plugin menus should surface as dropdown entries"
1250 );
1251 }
1252
1253 #[test]
1254 fn dropdown_lists_builtin_keybinding_maps() {
1255 let editor = make_editor(&[]);
1256 for map in ["default", "emacs", "vscode", "macos"] {
1257 let qualified = format!("switch_keybinding_map:{}", map);
1258 assert!(
1259 editor.available_actions.contains(&qualified),
1260 "expected `{}` in dropdown",
1261 qualified
1262 );
1263 }
1264 assert!(
1265 !editor
1266 .available_actions
1267 .iter()
1268 .any(|a| a == "switch_keybinding_map"),
1269 "bare `switch_keybinding_map` must not appear"
1270 );
1271 }
1272
1273 #[test]
1274 fn qualified_action_roundtrips_through_resolved_to_config() {
1275 let editor = make_editor(&[]);
1278 let rb = ResolvedBinding {
1279 key_display: "Alt+F".to_string(),
1280 action: "menu_open:File".to_string(),
1281 action_display: String::new(),
1282 context: "global".to_string(),
1283 source: BindingSource::Custom,
1284 key_code: KeyCode::Char('f'),
1285 modifiers: KeyModifiers::ALT,
1286 is_chord: false,
1287 plugin_name: None,
1288 command_name: None,
1289 original_config: None,
1290 };
1291 let kb = editor.resolved_to_config_keybinding(&rb);
1292 assert_eq!(kb.action, "menu_open");
1293 assert_eq!(
1294 kb.args.get("name").and_then(|v| v.as_str()),
1295 Some("File"),
1296 "the variant name must land in args.name, got {:?}",
1297 kb.args
1298 );
1299 }
1300}