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("file_explorer".to_string()),
756 ContextFilter::Specific("menu".to_string()),
757 ContextFilter::Specific("terminal".to_string()),
758 ];
759 for mode_ctx in &self.mode_contexts {
761 contexts.push(ContextFilter::Specific(mode_ctx.clone()));
762 }
763
764 let current_idx = contexts
765 .iter()
766 .position(|c| c == &self.context_filter)
767 .unwrap_or(0);
768 let next_idx = (current_idx + 1) % contexts.len();
769 self.context_filter = contexts.into_iter().nth(next_idx).unwrap();
770 self.apply_filters();
771 }
772
773 pub fn cycle_source_filter(&mut self) {
775 self.source_filter = match self.source_filter {
776 SourceFilter::All => SourceFilter::CustomOnly,
777 SourceFilter::CustomOnly => SourceFilter::KeymapOnly,
778 SourceFilter::KeymapOnly => SourceFilter::PluginOnly,
779 SourceFilter::PluginOnly => SourceFilter::All,
780 };
781 self.apply_filters();
782 }
783
784 pub fn open_add_dialog(&mut self) {
786 self.edit_dialog = Some(EditBindingState::new_add_with_modes(&self.mode_contexts));
787 }
788
789 pub fn open_edit_dialog(&mut self) {
791 if let Some(idx) = self.selected_binding_index() {
792 let binding = self.bindings[idx].clone();
793 self.edit_dialog = Some(EditBindingState::new_edit_with_modes(
794 idx,
795 &binding,
796 &self.mode_contexts,
797 ));
798 }
799 }
800
801 pub fn close_edit_dialog(&mut self) {
803 self.edit_dialog = None;
804 }
805
806 pub fn delete_selected(&mut self) -> DeleteResult {
816 let Some(idx) = self.selected_binding_index() else {
817 return DeleteResult::NothingSelected;
818 };
819
820 match self.bindings[idx].source {
821 BindingSource::Custom => {
822 let binding = &self.bindings[idx];
823 let action_name = binding.action.clone();
824
825 let config_kb = binding
830 .original_config
831 .clone()
832 .unwrap_or_else(|| self.resolved_to_config_keybinding(binding));
833
834 let found_in_adds = self.pending_adds.iter().position(|kb| {
838 kb.action == config_kb.action
839 && kb.key == config_kb.key
840 && kb.modifiers == config_kb.modifiers
841 && kb.when == config_kb.when
842 });
843 if let Some(pos) = found_in_adds {
844 self.pending_adds.remove(pos);
845 } else {
846 self.pending_removes.push(config_kb);
847 }
848
849 self.bindings.remove(idx);
850 self.has_changes = true;
851
852 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
854 if !still_bound {
855 let action_display = KeybindingResolver::format_action_from_str(&action_name);
856 self.bindings.push(ResolvedBinding {
857 key_display: String::new(),
858 action: action_name,
859 action_display,
860 context: String::new(),
861 source: BindingSource::Unbound,
862 key_code: KeyCode::Null,
863 modifiers: KeyModifiers::NONE,
864 is_chord: false,
865 plugin_name: None,
866 command_name: None,
867 original_config: None,
868 });
869 }
870
871 self.apply_filters();
872 DeleteResult::CustomRemoved
873 }
874 BindingSource::Keymap => {
875 let binding = &self.bindings[idx];
876 let action_name = binding.action.clone();
877
878 let noop_kb = Keybinding {
880 key: if binding.is_chord {
881 String::new()
882 } else {
883 key_code_to_config_name(binding.key_code)
884 },
885 modifiers: if binding.is_chord {
886 Vec::new()
887 } else {
888 modifiers_to_config_names(binding.modifiers)
889 },
890 keys: Vec::new(),
891 action: "noop".to_string(),
892 args: HashMap::new(),
893 when: if binding.context.is_empty() {
894 None
895 } else {
896 Some(binding.context.clone())
897 },
898 };
899 self.pending_adds.push(noop_kb);
900
901 let noop_display = KeybindingResolver::format_action_from_str("noop");
903 self.bindings[idx] = ResolvedBinding {
904 key_display: self.bindings[idx].key_display.clone(),
905 action: "noop".to_string(),
906 action_display: noop_display,
907 context: self.bindings[idx].context.clone(),
908 source: BindingSource::Custom,
909 key_code: self.bindings[idx].key_code,
910 modifiers: self.bindings[idx].modifiers,
911 is_chord: self.bindings[idx].is_chord,
912 plugin_name: self.bindings[idx].plugin_name.clone(),
913 command_name: None,
914 original_config: None,
915 };
916 self.has_changes = true;
917
918 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
920 if !still_bound {
921 let action_display = KeybindingResolver::format_action_from_str(&action_name);
922 self.bindings.push(ResolvedBinding {
923 key_display: String::new(),
924 action: action_name,
925 action_display,
926 context: String::new(),
927 source: BindingSource::Unbound,
928 key_code: KeyCode::Null,
929 modifiers: KeyModifiers::NONE,
930 is_chord: false,
931 plugin_name: None,
932 command_name: None,
933 original_config: None,
934 });
935 }
936
937 self.apply_filters();
938 DeleteResult::KeymapOverridden
939 }
940 BindingSource::Plugin => {
941 let binding = &self.bindings[idx];
943 let action_name = binding.action.clone();
944
945 let noop_kb = Keybinding {
946 key: if binding.is_chord {
947 String::new()
948 } else {
949 key_code_to_config_name(binding.key_code)
950 },
951 modifiers: if binding.is_chord {
952 Vec::new()
953 } else {
954 modifiers_to_config_names(binding.modifiers)
955 },
956 keys: Vec::new(),
957 action: "noop".to_string(),
958 args: HashMap::new(),
959 when: if binding.context.is_empty() {
960 None
961 } else {
962 Some(binding.context.clone())
963 },
964 };
965 self.pending_adds.push(noop_kb);
966
967 let noop_display = KeybindingResolver::format_action_from_str("noop");
968 self.bindings[idx] = ResolvedBinding {
969 key_display: self.bindings[idx].key_display.clone(),
970 action: "noop".to_string(),
971 action_display: noop_display,
972 context: self.bindings[idx].context.clone(),
973 source: BindingSource::Custom,
974 key_code: self.bindings[idx].key_code,
975 modifiers: self.bindings[idx].modifiers,
976 is_chord: self.bindings[idx].is_chord,
977 plugin_name: self.bindings[idx].plugin_name.clone(),
978 command_name: None,
979 original_config: None,
980 };
981 self.has_changes = true;
982
983 let still_bound = self.bindings.iter().any(|b| b.action == action_name);
984 if !still_bound {
985 let action_display = KeybindingResolver::format_action_from_str(&action_name);
986 self.bindings.push(ResolvedBinding {
987 key_display: String::new(),
988 action: action_name,
989 action_display,
990 context: String::new(),
991 source: BindingSource::Unbound,
992 key_code: KeyCode::Null,
993 modifiers: KeyModifiers::NONE,
994 is_chord: false,
995 plugin_name: None,
996 command_name: None,
997 original_config: None,
998 });
999 }
1000
1001 self.apply_filters();
1002 DeleteResult::KeymapOverridden
1003 }
1004 BindingSource::Unbound => DeleteResult::CannotDelete,
1005 }
1006 }
1007
1008 fn resolved_to_config_keybinding(&self, binding: &ResolvedBinding) -> Keybinding {
1010 let (action, args) = Action::unqualify_action(&binding.action);
1011 Keybinding {
1012 key: if binding.is_chord {
1013 String::new()
1014 } else {
1015 key_code_to_config_name(binding.key_code)
1016 },
1017 modifiers: if binding.is_chord {
1018 Vec::new()
1019 } else {
1020 modifiers_to_config_names(binding.modifiers)
1021 },
1022 keys: Vec::new(),
1023 action,
1024 args,
1025 when: if binding.context.is_empty() {
1026 None
1027 } else {
1028 Some(binding.context.clone())
1029 },
1030 }
1031 }
1032
1033 pub fn apply_edit_dialog(&mut self) -> Option<String> {
1036 let dialog = self.edit_dialog.take()?;
1037
1038 if dialog.key_code.is_none() || dialog.action_text.is_empty() {
1039 self.edit_dialog = Some(dialog);
1040 return Some(t!("keybinding_editor.error_key_action_required").to_string());
1041 }
1042
1043 if !self.is_valid_action(&dialog.action_text) {
1045 let err_msg = t!(
1046 "keybinding_editor.error_unknown_action",
1047 action = &dialog.action_text
1048 )
1049 .to_string();
1050 let mut dialog = dialog;
1051 dialog.action_error = Some(
1052 t!(
1053 "keybinding_editor.error_unknown_action_short",
1054 action = &dialog.action_text
1055 )
1056 .to_string(),
1057 );
1058 self.edit_dialog = Some(dialog);
1059 return Some(err_msg);
1060 }
1061
1062 let key_code = dialog.key_code.unwrap();
1063 let modifiers = dialog.modifiers;
1064 let key_name = key_code_to_config_name(key_code);
1065 let modifier_names = modifiers_to_config_names(modifiers);
1066
1067 let (bare_action, args) = Action::unqualify_action(&dialog.action_text);
1071
1072 let new_binding = Keybinding {
1073 key: key_name,
1074 modifiers: modifier_names,
1075 keys: Vec::new(),
1076 action: bare_action.clone(),
1077 args: args.clone(),
1078 when: Some(dialog.context.clone()),
1079 };
1080
1081 self.pending_adds.push(new_binding.clone());
1083 self.has_changes = true;
1084
1085 let key_display = format_keybinding(&key_code, &modifiers);
1087 let action_display =
1088 KeybindingResolver::format_action_from_str_with_args(&bare_action, &args);
1089
1090 let preserved_plugin_name = dialog
1093 .editing_index
1094 .and_then(|idx| self.bindings.get(idx))
1095 .and_then(|b| b.plugin_name.clone());
1096
1097 let resolved = ResolvedBinding {
1098 key_display,
1099 action: dialog.action_text,
1100 action_display,
1101 context: dialog.context,
1102 source: BindingSource::Custom,
1103 key_code,
1104 modifiers,
1105 is_chord: false,
1106 plugin_name: preserved_plugin_name,
1107 command_name: None,
1108 original_config: None,
1109 };
1110
1111 if let Some(edit_idx) = dialog.editing_index {
1112 if edit_idx < self.bindings.len() {
1114 self.bindings[edit_idx] = resolved;
1115 }
1116 } else {
1117 self.bindings.push(resolved);
1119 }
1120
1121 self.apply_filters();
1122 None
1123 }
1124
1125 pub fn find_conflicts(
1127 &self,
1128 key_code: KeyCode,
1129 modifiers: KeyModifiers,
1130 context: &str,
1131 ) -> Vec<String> {
1132 let mut conflicts = Vec::new();
1133
1134 for binding in &self.bindings {
1135 if !binding.is_chord
1136 && binding.key_code == key_code
1137 && binding.modifiers == modifiers
1138 && (binding.context == context
1139 || binding.context == "global"
1140 || context == "global")
1141 {
1142 conflicts.push(format!(
1143 "{} ({}, {})",
1144 binding.action_display,
1145 binding.context,
1146 match binding.source {
1147 BindingSource::Custom => "custom",
1148 BindingSource::Plugin => "plugin",
1149 _ => "keymap",
1150 }
1151 ));
1152 }
1153 }
1154
1155 conflicts
1156 }
1157
1158 pub fn get_custom_bindings(&self) -> Vec<Keybinding> {
1160 self.pending_adds.clone()
1161 }
1162
1163 pub fn get_pending_removes(&self) -> &[Keybinding] {
1165 &self.pending_removes
1166 }
1167
1168 pub fn context_filter_display(&self) -> &str {
1170 match &self.context_filter {
1171 ContextFilter::All => "All",
1172 ContextFilter::Specific(ctx) => ctx.as_str(),
1173 }
1174 }
1175
1176 pub fn source_filter_display(&self) -> &str {
1178 match &self.source_filter {
1179 SourceFilter::All => "All",
1180 SourceFilter::KeymapOnly => "Keymap",
1181 SourceFilter::CustomOnly => "Custom",
1182 SourceFilter::PluginOnly => "Plugin",
1183 }
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use crate::input::buffer_mode::ModeRegistry;
1191
1192 fn make_editor(extra_menus: &[&str]) -> KeybindingEditor {
1193 let config = Config::default();
1194 let resolver = KeybindingResolver::new(&config);
1195 let mode_registry = ModeRegistry::new();
1196 let cmd_registry = CommandRegistry::new();
1197 let mut menu_names: Vec<String> = ["File", "Edit", "View"]
1198 .iter()
1199 .map(|s| s.to_string())
1200 .collect();
1201 menu_names.extend(extra_menus.iter().map(|s| s.to_string()));
1202 KeybindingEditor::new(
1203 &config,
1204 &resolver,
1205 &mode_registry,
1206 &cmd_registry,
1207 String::from("/tmp/fresh-config.toml"),
1208 &menu_names,
1209 )
1210 }
1211
1212 #[test]
1213 fn dropdown_lists_menu_open_variants_not_bare_entry() {
1214 let editor = make_editor(&[]);
1218 assert!(
1219 !editor.available_actions.iter().any(|a| a == "menu_open"),
1220 "bare `menu_open` must not appear — it is un-parseable without args"
1221 );
1222 assert!(
1223 editor
1224 .available_actions
1225 .contains(&"menu_open:File".to_string()),
1226 "expected dropdown to list `menu_open:File`, got {:?}",
1227 editor.available_actions
1228 );
1229 assert!(
1230 editor
1231 .available_actions
1232 .contains(&"menu_open:Edit".to_string()),
1233 "expected dropdown to list `menu_open:Edit`"
1234 );
1235 }
1236
1237 #[test]
1238 fn dropdown_includes_plugin_menus_passed_in() {
1239 let editor = make_editor(&["MyPluginMenu"]);
1240 assert!(
1241 editor
1242 .available_actions
1243 .contains(&"menu_open:MyPluginMenu".to_string()),
1244 "plugin menus should surface as dropdown entries"
1245 );
1246 }
1247
1248 #[test]
1249 fn dropdown_lists_builtin_keybinding_maps() {
1250 let editor = make_editor(&[]);
1251 for map in ["default", "emacs", "vscode", "macos"] {
1252 let qualified = format!("switch_keybinding_map:{}", map);
1253 assert!(
1254 editor.available_actions.contains(&qualified),
1255 "expected `{}` in dropdown",
1256 qualified
1257 );
1258 }
1259 assert!(
1260 !editor
1261 .available_actions
1262 .iter()
1263 .any(|a| a == "switch_keybinding_map"),
1264 "bare `switch_keybinding_map` must not appear"
1265 );
1266 }
1267
1268 #[test]
1269 fn qualified_action_roundtrips_through_resolved_to_config() {
1270 let editor = make_editor(&[]);
1273 let rb = ResolvedBinding {
1274 key_display: "Alt+F".to_string(),
1275 action: "menu_open:File".to_string(),
1276 action_display: String::new(),
1277 context: "global".to_string(),
1278 source: BindingSource::Custom,
1279 key_code: KeyCode::Char('f'),
1280 modifiers: KeyModifiers::ALT,
1281 is_chord: false,
1282 plugin_name: None,
1283 command_name: None,
1284 original_config: None,
1285 };
1286 let kb = editor.resolved_to_config_keybinding(&rb);
1287 assert_eq!(kb.action, "menu_open");
1288 assert_eq!(
1289 kb.args.get("name").and_then(|v| v.as_str()),
1290 Some("File"),
1291 "the variant name must land in args.name, got {:?}",
1292 kb.args
1293 );
1294 }
1295}