1use crate::input::{InputRegistry, KeyMap, ParseKeyError, try_parse_binding};
2use crate::runtime::{TuiPages, TuiPagesBuilder};
3use std::collections::{HashMap, HashSet};
4use std::fmt;
5use toml::Value;
6use tracing::warn;
7
8use super::action::NavigationAction;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct NavigationPreset {
12 sections: Vec<NavigationPresetSection>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct NavigationPresetSection {
17 pub name: String,
18 pub mode: String,
19 pub bindings: Vec<NavigationPresetBinding>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct NavigationPresetBinding {
24 pub action: NavigationAction,
25 pub keys: Vec<String>,
26}
27
28#[derive(Debug)]
29pub enum NavigationPresetError {
30 Toml(toml::de::Error),
31 Issues(Vec<NavigationPresetIssue>),
32 UnknownSection { section: String },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum NavigationPresetIssue {
37 RootNotTable,
38 SectionNotTable {
39 section: String,
40 },
41 ModeNotString {
42 section: String,
43 },
44 UnknownAction {
45 section: String,
46 action: String,
47 },
48 BindingsNotStringList {
49 section: String,
50 action: String,
51 },
52 EmptyBindings {
53 section: String,
54 action: String,
55 },
56 InvalidBinding {
57 section: String,
58 action: NavigationAction,
59 binding: String,
60 source: ParseKeyError,
61 },
62 DuplicateBinding {
63 section: String,
64 mode: String,
65 binding: String,
66 first_action: NavigationAction,
67 second_action: NavigationAction,
68 },
69 ExistingBindingConflict {
70 section: String,
71 mode: String,
72 binding: String,
73 action: NavigationAction,
74 existing_action: Option<NavigationAction>,
75 },
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum NavigationConflictPolicy {
80 Allow,
81 Deny,
82}
83
84impl fmt::Display for NavigationPresetError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 NavigationPresetError::Toml(err) => write!(f, "invalid TOML: {err}"),
88 NavigationPresetError::Issues(issues) => {
89 write!(f, "{} keybinding preset issue(s)", issues.len())?;
90 for issue in issues {
91 write!(f, "; {issue}")?;
92 }
93 Ok(())
94 }
95 NavigationPresetError::UnknownSection { section } => {
96 write!(f, "unknown keybinding section {section:?}")
97 }
98 }
99 }
100}
101
102impl fmt::Display for NavigationPresetIssue {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 NavigationPresetIssue::RootNotTable => {
106 write!(f, "keybinding preset must be a TOML table")
107 }
108 NavigationPresetIssue::SectionNotTable { section } => {
109 write!(f, "keybinding section {section:?} must be a table")
110 }
111 NavigationPresetIssue::ModeNotString { section } => {
112 write!(f, "keybinding section {section:?} has a non-string mode")
113 }
114 NavigationPresetIssue::UnknownAction { section, action } => {
115 write!(
116 f,
117 "unknown navigation action {action:?} in section {section:?}"
118 )
119 }
120 NavigationPresetIssue::BindingsNotStringList { section, action } => {
121 write!(
122 f,
123 "bindings for action {action:?} in section {section:?} must be a string or string list"
124 )
125 }
126 NavigationPresetIssue::EmptyBindings { section, action } => {
127 write!(
128 f,
129 "action {action:?} in section {section:?} has no bindings"
130 )
131 }
132 NavigationPresetIssue::InvalidBinding {
133 section,
134 action,
135 binding,
136 source,
137 } => {
138 write!(
139 f,
140 "invalid binding {binding:?} for {} in section {section:?}: {source}",
141 action.as_name()
142 )
143 }
144 NavigationPresetIssue::DuplicateBinding {
145 section,
146 mode,
147 binding,
148 first_action,
149 second_action,
150 } => {
151 write!(
152 f,
153 "binding {binding:?} in mode {mode:?}, section {section:?} is assigned to both {} and {}",
154 first_action.as_name(),
155 second_action.as_name()
156 )
157 }
158 NavigationPresetIssue::ExistingBindingConflict {
159 section,
160 mode,
161 binding,
162 action,
163 existing_action,
164 } => {
165 let existing = existing_action
166 .map(|action| action.info().name)
167 .unwrap_or("an existing application action");
168 write!(
169 f,
170 "binding {binding:?} for {} in mode {mode:?}, section {section:?} conflicts with {existing}",
171 action.as_name()
172 )
173 }
174 }
175 }
176}
177
178impl std::error::Error for NavigationPresetError {
179 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
180 match self {
181 NavigationPresetError::Toml(err) => Some(err),
182 _ => None,
183 }
184 }
185}
186
187impl NavigationPreset {
188 pub fn from_toml(source: &str) -> Result<Self, NavigationPresetError> {
189 let (preset, issues) = Self::from_toml_lenient(source)?;
190 if issues.is_empty() {
191 return Ok(preset);
192 }
193 Err(NavigationPresetError::Issues(issues))
194 }
195
196 pub(crate) fn from_toml_lenient(
197 source: &str,
198 ) -> Result<(Self, Vec<NavigationPresetIssue>), NavigationPresetError> {
199 if source.trim().is_empty() {
200 return Ok((
201 Self {
202 sections: Vec::new(),
203 },
204 Vec::new(),
205 ));
206 }
207 let value = toml::from_str::<Value>(source).map_err(NavigationPresetError::Toml)?;
208 let Some(table) = value.as_table() else {
209 return Ok((
210 Self {
211 sections: Vec::new(),
212 },
213 vec![NavigationPresetIssue::RootNotTable],
214 ));
215 };
216
217 let mut sections = Vec::with_capacity(table.len());
218 let mut issues = Vec::new();
219 for (section_name, section_value) in table {
220 let Some(section) = section_value.as_table() else {
221 issues.push(NavigationPresetIssue::SectionNotTable {
222 section: section_name.clone(),
223 });
224 continue;
225 };
226 let mode = match section.get("mode") {
227 Some(value) => value.as_str().map(ToString::to_string).unwrap_or_else(|| {
228 issues.push(NavigationPresetIssue::ModeNotString {
229 section: section_name.clone(),
230 });
231 section_name.clone()
232 }),
233 None => section_name.clone(),
234 };
235
236 let mut bindings = Vec::new();
237 for (action_name, bindings_value) in section {
238 if action_name == "mode" {
239 continue;
240 }
241
242 let Ok(action) = action_name.parse::<NavigationAction>() else {
243 issues.push(NavigationPresetIssue::UnknownAction {
244 section: section_name.clone(),
245 action: action_name.clone(),
246 });
247 continue;
248 };
249
250 let Some(keys) =
251 parse_string_list(section_name, action_name, bindings_value, &mut issues)
252 else {
253 continue;
254 };
255 if keys.is_empty() {
256 issues.push(NavigationPresetIssue::EmptyBindings {
257 section: section_name.clone(),
258 action: action_name.clone(),
259 });
260 continue;
261 }
262 bindings.push(NavigationPresetBinding { action, keys });
263 }
264
265 sections.push(NavigationPresetSection {
266 name: section_name.clone(),
267 mode,
268 bindings,
269 });
270 }
271
272 let preset = Self { sections };
273 issues.extend(preset.collect_binding_issues());
274 Ok((preset, issues))
275 }
276
277 pub fn sections(&self) -> &[NavigationPresetSection] {
278 &self.sections
279 }
280
281 pub fn section(&self, name: &str) -> Option<&NavigationPresetSection> {
282 self.sections.iter().find(|section| section.name == name)
283 }
284
285 pub fn validate(&self) -> Result<(), NavigationPresetError> {
286 let issues = self.collect_binding_issues();
287 if issues.is_empty() {
288 Ok(())
289 } else {
290 Err(NavigationPresetError::Issues(issues))
291 }
292 }
293
294 pub fn validation_issues(&self) -> Vec<NavigationPresetIssue> {
295 self.collect_binding_issues()
296 }
297
298 pub fn apply_to_registry<A>(
299 &self,
300 registry: &mut InputRegistry<A>,
301 ) -> Result<(), NavigationPresetError>
302 where
303 A: From<NavigationAction>,
304 {
305 self.validate()?;
306 for section in &self.sections {
307 section.bind_validated_to_map(registry.map_mut(section.mode.as_str()));
308 }
309 Ok(())
310 }
311
312 pub fn apply_to_registry_checked<A>(
313 &self,
314 registry: &mut InputRegistry<A>,
315 ) -> Result<(), NavigationPresetError>
316 where
317 A: From<NavigationAction> + PartialEq,
318 {
319 self.validate_against_registry(registry, NavigationConflictPolicy::Deny, false)?;
320 for section in &self.sections {
321 section.bind_validated_to_map(registry.map_mut(section.mode.as_str()));
322 }
323 Ok(())
324 }
325
326 pub fn remap_registry<A>(
327 &self,
328 registry: &mut InputRegistry<A>,
329 ) -> Result<(), NavigationPresetError>
330 where
331 A: From<NavigationAction> + PartialEq,
332 {
333 self.validate_against_registry(registry, NavigationConflictPolicy::Deny, true)?;
334 let mut cleared = HashSet::new();
335 for section in &self.sections {
336 for binding in §ion.bindings {
337 if cleared.insert((section.mode.clone(), binding.action)) {
338 registry
339 .map_mut(section.mode.as_str())
340 .unbind_action(&A::from(binding.action));
341 }
342 }
343 }
344
345 for section in &self.sections {
346 section.bind_validated_to_map(registry.map_mut(section.mode.as_str()));
347 }
348 Ok(())
349 }
350
351 pub fn bind_section_to_map<A>(
352 &self,
353 name: &str,
354 map: &mut KeyMap<A>,
355 ) -> Result<(), NavigationPresetError>
356 where
357 A: From<NavigationAction>,
358 {
359 let section = self
360 .section(name)
361 .ok_or_else(|| NavigationPresetError::UnknownSection {
362 section: name.to_string(),
363 })?;
364 section.bind_to_map(map)
365 }
366
367 pub fn validate_against_registry<A>(
368 &self,
369 registry: &InputRegistry<A>,
370 conflict_policy: NavigationConflictPolicy,
371 remap: bool,
372 ) -> Result<(), NavigationPresetError>
373 where
374 A: From<NavigationAction> + PartialEq,
375 {
376 let mut issues = self.collect_binding_issues();
377 if conflict_policy == NavigationConflictPolicy::Deny {
378 issues.extend(self.collect_registry_conflicts(registry, remap));
379 }
380 if issues.is_empty() {
381 Ok(())
382 } else {
383 Err(NavigationPresetError::Issues(issues))
384 }
385 }
386
387 fn collect_binding_issues(&self) -> Vec<NavigationPresetIssue> {
388 let mut issues = Vec::new();
389 let mut seen = HashMap::new();
390 for section in &self.sections {
391 for binding in §ion.bindings {
392 for key in &binding.keys {
393 let sequence = match try_parse_binding(key) {
394 Ok(sequence) => sequence,
395 Err(source) => {
396 issues.push(NavigationPresetIssue::InvalidBinding {
397 section: section.name.clone(),
398 action: binding.action,
399 binding: key.clone(),
400 source,
401 });
402 continue;
403 }
404 };
405 let previous = seen.insert(
406 (section.mode.clone(), sequence),
407 (section.name.clone(), binding.action),
408 );
409 if let Some((first_section, first_action)) = previous {
410 if first_action != binding.action {
411 issues.push(NavigationPresetIssue::DuplicateBinding {
412 section: section.name.clone(),
413 mode: section.mode.clone(),
414 binding: key.clone(),
415 first_action,
416 second_action: binding.action,
417 });
418 seen.insert(
419 (
420 section.mode.clone(),
421 try_parse_binding(key).expect("binding was already parsed"),
422 ),
423 (first_section, first_action),
424 );
425 }
426 }
427 }
428 }
429 }
430 issues
431 }
432
433 fn collect_registry_conflicts<A>(
434 &self,
435 registry: &InputRegistry<A>,
436 remap: bool,
437 ) -> Vec<NavigationPresetIssue>
438 where
439 A: From<NavigationAction> + PartialEq,
440 {
441 let mut replaced = HashSet::new();
442 if remap {
443 for section in &self.sections {
444 for binding in §ion.bindings {
445 replaced.insert((section.mode.clone(), binding.action));
446 }
447 }
448 }
449
450 let mut issues = Vec::new();
451 for section in &self.sections {
452 let Some(map) = registry.maps.get(section.mode.as_str()) else {
453 continue;
454 };
455 for binding in §ion.bindings {
456 for key in &binding.keys {
457 let Ok(sequence) = try_parse_binding(key) else {
458 continue;
459 };
460 let Some(existing) = map.bindings.get(&sequence) else {
461 continue;
462 };
463 if *existing == A::from(binding.action) {
464 continue;
465 }
466
467 let existing_action = navigation_action_for(existing);
468 if let Some(existing_action) = existing_action {
469 if replaced.contains(&(section.mode.clone(), existing_action)) {
470 continue;
471 }
472 }
473
474 issues.push(NavigationPresetIssue::ExistingBindingConflict {
475 section: section.name.clone(),
476 mode: section.mode.clone(),
477 binding: key.clone(),
478 action: binding.action,
479 existing_action,
480 });
481 }
482 }
483 }
484 issues
485 }
486}
487
488impl NavigationPresetSection {
489 pub fn validate(&self) -> Result<(), NavigationPresetError> {
490 let issues = self.validation_issues();
491 if issues.is_empty() {
492 Ok(())
493 } else {
494 Err(NavigationPresetError::Issues(issues))
495 }
496 }
497
498 pub fn validation_issues(&self) -> Vec<NavigationPresetIssue> {
499 let preset = NavigationPreset {
500 sections: vec![self.clone()],
501 };
502 preset.collect_binding_issues()
503 }
504
505 pub fn bind_to_map<A>(&self, map: &mut KeyMap<A>) -> Result<(), NavigationPresetError>
506 where
507 A: From<NavigationAction>,
508 {
509 self.validate()?;
510 self.bind_validated_to_map(map);
511 Ok(())
512 }
513
514 fn bind_validated_to_map<A>(&self, map: &mut KeyMap<A>)
515 where
516 A: From<NavigationAction>,
517 {
518 for binding in &self.bindings {
519 for key in &binding.keys {
520 let sequence = try_parse_binding(key).expect("binding was validated");
521 map.bind(sequence, A::from(binding.action));
522 }
523 }
524 }
525}
526
527pub fn apply_navigation_preset_toml<A>(
528 registry: &mut InputRegistry<A>,
529 source: &str,
530) -> Result<(), NavigationPresetError>
531where
532 A: From<NavigationAction> + PartialEq,
533{
534 let preset = parse_user_preset_toml(source)?;
535 if let Err(err) = preset.apply_to_registry_checked(registry) {
536 warn!(error = %err, "failed to apply navigation keybinding preset");
537 return Err(err);
538 }
539 Ok(())
540}
541
542pub fn remap_navigation_preset_toml<A>(
543 registry: &mut InputRegistry<A>,
544 source: &str,
545) -> Result<(), NavigationPresetError>
546where
547 A: From<NavigationAction> + PartialEq,
548{
549 let preset = parse_user_preset_toml(source)?;
550 if let Err(err) = preset.remap_registry(registry) {
551 warn!(error = %err, "failed to remap navigation keybinding preset");
552 return Err(err);
553 }
554 Ok(())
555}
556
557impl<V, A, O, M, Pages, Handler, Hooks> TuiPagesBuilder<V, A, O, M, Pages, Handler, Hooks>
558where
559 A: From<NavigationAction> + PartialEq,
560{
561 pub fn navigation_preset_toml(mut self, source: &str) -> Result<Self, NavigationPresetError> {
562 apply_navigation_preset_toml(&mut self.input_registry, source)?;
563 Ok(self)
564 }
565}
566
567impl<V, A, O, M, Pages, Handler, Hooks> TuiPagesBuilder<V, A, O, M, Pages, Handler, Hooks>
568where
569 A: From<NavigationAction> + PartialEq,
570{
571 pub fn remap_navigation_preset_toml(
572 mut self,
573 source: &str,
574 ) -> Result<Self, NavigationPresetError> {
575 remap_navigation_preset_toml(&mut self.input_registry, source)?;
576 Ok(self)
577 }
578}
579
580impl<V, A, Pages, Handler, O, M, Hooks> TuiPages<V, A, Pages, Handler, O, M, Hooks>
581where
582 A: From<NavigationAction> + PartialEq,
583{
584 pub fn apply_navigation_preset_toml(
585 &mut self,
586 source: &str,
587 ) -> Result<(), NavigationPresetError> {
588 apply_navigation_preset_toml(&mut self.input.registry, source)?;
589 self.input.reset();
590 self.active_owner = None;
591 Ok(())
592 }
593}
594
595impl<V, A, Pages, Handler, O, M, Hooks> TuiPages<V, A, Pages, Handler, O, M, Hooks>
596where
597 A: From<NavigationAction> + PartialEq,
598{
599 pub fn remap_navigation_preset_toml(
600 &mut self,
601 source: &str,
602 ) -> Result<(), NavigationPresetError> {
603 remap_navigation_preset_toml(&mut self.input.registry, source)?;
604 self.input.reset();
605 self.active_owner = None;
606 Ok(())
607 }
608}
609
610pub(crate) fn builtin_preset(name: &str, source: &str) -> NavigationPreset {
611 NavigationPreset::from_toml(source)
612 .unwrap_or_else(|err| panic!("invalid built-in {name} keybinding preset: {err}"))
613}
614
615fn parse_user_preset_toml(source: &str) -> Result<NavigationPreset, NavigationPresetError> {
616 match NavigationPreset::from_toml(source) {
617 Ok(preset) => Ok(preset),
618 Err(err) => {
619 warn!(error = %err, "failed to parse navigation keybinding preset");
620 Err(err)
621 }
622 }
623}
624
625pub(crate) fn parse_string_list(
626 section: &str,
627 action: &str,
628 value: &Value,
629 issues: &mut Vec<NavigationPresetIssue>,
630) -> Option<Vec<String>> {
631 let Some(keys) = parse_string_list_value(value) else {
632 issues.push(NavigationPresetIssue::BindingsNotStringList {
633 section: section.to_string(),
634 action: action.to_string(),
635 });
636 return None;
637 };
638 Some(keys)
639}
640
641fn parse_string_list_value(value: &Value) -> Option<Vec<String>> {
642 if let Some(text) = value.as_str() {
643 return Some(vec![text.to_string()]);
644 }
645
646 value
647 .as_array()?
648 .iter()
649 .map(|item| item.as_str().map(ToString::to_string))
650 .collect()
651}
652
653fn navigation_action_for<A>(action: &A) -> Option<NavigationAction>
654where
655 A: From<NavigationAction> + PartialEq,
656{
657 for nav in NavigationAction::all() {
658 if *action == A::from(nav) {
659 return Some(nav);
660 }
661 }
662 None
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::input::InputPipeline;
669 use crate::runtime::modes;
670 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
671
672 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
673 enum TestAction {
674 Nav(NavigationAction),
675 }
676
677 impl From<NavigationAction> for TestAction {
678 fn from(value: NavigationAction) -> Self {
679 TestAction::Nav(value)
680 }
681 }
682
683 #[test]
684 fn toml_preset_applies_to_registry_modes() {
685 let preset = r#"
686[general]
687mode = "general"
688focus_next = ["j"]
689
690[global]
691mode = "global"
692quit = "ctrl+c"
693"#;
694 let mut registry = InputRegistry::empty();
695 apply_navigation_preset_toml::<TestAction>(&mut registry, preset).unwrap();
696 let mut pipeline = InputPipeline::new(registry, 1000);
697
698 let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty());
699 match pipeline.process(j, &[modes::GENERAL], false) {
700 crate::input::PipelineResponse::Execute(TestAction::Nav(
701 NavigationAction::FocusNext,
702 )) => {}
703 other => panic!("expected FocusNext, got {other:?}"),
704 }
705
706 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
707 match pipeline.process(ctrl_c, &[modes::GLOBAL], false) {
708 crate::input::PipelineResponse::Execute(TestAction::Nav(NavigationAction::Quit)) => {}
709 other => panic!("expected Quit, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn toml_preset_reports_unknown_actions() {
715 let preset = r#"
716[general]
717does_not_exist = ["j"]
718"#;
719 let err = NavigationPreset::from_toml(preset).unwrap_err();
720 let NavigationPresetError::Issues(issues) = err else {
721 panic!("expected validation issues");
722 };
723 assert!(
724 issues
725 .iter()
726 .any(|issue| matches!(issue, NavigationPresetIssue::UnknownAction { .. }))
727 );
728 }
729
730 #[test]
731 fn toml_preset_collects_multiple_issues() {
732 let preset = r#"
733[general]
734does_not_exist = ["j"]
735focus_next = ["ctrl+shft+j"]
736focus_prev = []
737"#;
738 let mut registry = InputRegistry::empty();
739 let err = apply_navigation_preset_toml::<TestAction>(&mut registry, preset).unwrap_err();
740 let NavigationPresetError::Issues(issues) = err else {
741 panic!("expected validation issues");
742 };
743 assert_eq!(issues.len(), 3);
744 assert!(
745 issues
746 .iter()
747 .any(|issue| matches!(issue, NavigationPresetIssue::UnknownAction { .. }))
748 );
749 assert!(
750 issues
751 .iter()
752 .any(|issue| matches!(issue, NavigationPresetIssue::InvalidBinding { .. }))
753 );
754 assert!(
755 issues
756 .iter()
757 .any(|issue| matches!(issue, NavigationPresetIssue::EmptyBindings { .. }))
758 );
759 }
760
761 #[test]
762 fn toml_preset_reports_duplicate_bindings() {
763 let preset = r#"
764[general]
765focus_next = ["j"]
766focus_prev = ["j"]
767"#;
768 let err = NavigationPreset::from_toml(preset).unwrap_err();
769 let NavigationPresetError::Issues(issues) = err else {
770 panic!("expected validation issues");
771 };
772 assert!(
773 issues
774 .iter()
775 .any(|issue| matches!(issue, NavigationPresetIssue::DuplicateBinding { .. }))
776 );
777 }
778
779 #[test]
780 fn toml_preset_detects_duplicate_binding_aliases() {
781 let preset = r#"
782[general]
783focus_next = ["shift+tab"]
784focus_prev = ["backtab"]
785"#;
786 let err = NavigationPreset::from_toml(preset).unwrap_err();
787 let NavigationPresetError::Issues(issues) = err else {
788 panic!("expected validation issues");
789 };
790 assert!(
791 issues
792 .iter()
793 .any(|issue| matches!(issue, NavigationPresetIssue::DuplicateBinding { .. }))
794 );
795 }
796
797 #[test]
798 fn toml_remap_replaces_actions_it_mentions() {
799 let mut registry = InputRegistry::empty();
800 registry.map_mut(modes::GENERAL.as_str()).bind(
801 try_parse_binding("j").unwrap(),
802 TestAction::Nav(NavigationAction::FocusNext),
803 );
804
805 let preset = r#"
806[general]
807mode = "general"
808focus_next = ["ctrl+n"]
809"#;
810 remap_navigation_preset_toml::<TestAction>(&mut registry, preset).unwrap();
811 let mut pipeline = InputPipeline::new(registry, 1000);
812
813 let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty());
814 match pipeline.process(j, &[modes::GENERAL], false) {
815 crate::input::PipelineResponse::Type(_) => {}
816 other => panic!("expected j to be unbound, got {other:?}"),
817 }
818
819 let ctrl_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL);
820 match pipeline.process(ctrl_n, &[modes::GENERAL], false) {
821 crate::input::PipelineResponse::Execute(TestAction::Nav(
822 NavigationAction::FocusNext,
823 )) => {}
824 other => panic!("expected FocusNext, got {other:?}"),
825 }
826 }
827
828 #[test]
829 fn toml_remap_rejects_conflicts_with_remaining_defaults() {
830 let mut registry = InputRegistry::empty();
831 registry.map_mut(modes::GENERAL.as_str()).bind(
832 try_parse_binding("ctrl+n").unwrap(),
833 TestAction::Nav(NavigationAction::NextPane),
834 );
835
836 let preset = r#"
837[general]
838mode = "general"
839focus_next = ["ctrl+n"]
840"#;
841 let err = remap_navigation_preset_toml::<TestAction>(&mut registry, preset).unwrap_err();
842 let NavigationPresetError::Issues(issues) = err else {
843 panic!("expected validation issues");
844 };
845 assert!(
846 issues.iter().any(|issue| matches!(
847 issue,
848 NavigationPresetIssue::ExistingBindingConflict { .. }
849 ))
850 );
851
852 let mut pipeline = InputPipeline::new(registry, 1000);
853 let ctrl_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL);
854 match pipeline.process(ctrl_n, &[modes::GENERAL], false) {
855 crate::input::PipelineResponse::Execute(TestAction::Nav(
856 NavigationAction::NextPane,
857 )) => {}
858 other => panic!("expected existing NextPane binding to remain, got {other:?}"),
859 }
860 }
861}