Skip to main content

tui_pages/keybindings/
preset.rs

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 &section.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 &section.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 &section.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 &section.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}