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