Skip to main content

tui_pages/input/
report.rs

1//! Introspection over keybindings: where a binding came from, what an action
2//! is, and where bindings collide.
3//!
4//! The runtime [`InputRegistry`](crate::input::InputRegistry) is tuned for fast
5//! lookups and carries no provenance. This module sits *beside* it: a
6//! [`BindingCatalog`] is a flat, source-tagged list of bindings built from a
7//! registry (or from canvas defaults), suitable for help screens, `:bindings`
8//! panels, and conflict diagnostics. None of this is on the hot input path.
9
10use crate::input::InputRegistry;
11use crate::input::KeyChord;
12
13#[cfg(feature = "canvas")]
14use crate::canvas::CanvasAction;
15
16/// Where a binding originated. Lets a diagnostics view explain *why* a key does
17/// what it does ("bound by your config" vs "a built-in default").
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum BindingSource {
20    /// Shipped by the application/runtime as a hard-coded default.
21    Builtin,
22    /// Loaded from the user's configuration file.
23    Config,
24    /// A default owned by the canvas editing layer.
25    CanvasBuiltin,
26    /// Installed at runtime (e.g. an interactive remap).
27    Runtime,
28    /// Provenance not tracked.
29    Unknown,
30}
31
32/// Which input layer owns a binding. The keymap layer is the global
33/// [`InputRegistry`](crate::input::InputRegistry); the canvas layer is the
34/// modal editing engine inside canvas widgets.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum BindingLayer {
37    Keymap,
38    Canvas,
39}
40
41/// One binding with full provenance: which layer and mode own it, the exact
42/// chord sequence, the action it fires, and where it came from.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct BindingInfo<A> {
45    pub layer: BindingLayer,
46    pub mode: String,
47    pub sequence: Vec<KeyChord>,
48    pub action: A,
49    pub source: BindingSource,
50}
51
52/// When a keymap binding and a canvas binding share a sequence, this records
53/// which layer the runtime lets win for that input context.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum CanvasRoutingPrecedence {
56    /// The global keymap is consulted first (command-context keys).
57    KeymapFirst,
58    /// The canvas editing layer is consulted first (text/editing keys).
59    CanvasFirst,
60    /// A multi-key canvas flow is mid-sequence and owns the next keys.
61    StickyOwner,
62}
63
64/// A detected collision between bindings. Most variants are *informational* —
65/// they describe behaviour the routing rules already resolve deterministically,
66/// but which a user may not expect.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum BindingConflict<A> {
69    /// The same sequence is bound to two different actions in one mode.
70    SameModeDuplicate {
71        mode: String,
72        sequence: Vec<KeyChord>,
73        first: A,
74        second: A,
75    },
76    /// The same sequence is bound in two simultaneously-active modes; the
77    /// earlier mode in the active stack shadows the later one.
78    ActiveModeShadow {
79        sequence: Vec<KeyChord>,
80        first_mode: String,
81        first: A,
82        shadowed_mode: String,
83        shadowed: A,
84    },
85    /// A keymap binding and a canvas binding share a sequence in the same mode.
86    #[cfg(feature = "canvas")]
87    CanvasOverlap {
88        mode: String,
89        sequence: Vec<KeyChord>,
90        keymap_action: A,
91        canvas_action: CanvasAction,
92        routing: CanvasRoutingPrecedence,
93    },
94}
95
96/// A flat, source-tagged view of every binding in a registry. Built lazily for
97/// help/diagnostics; never consulted on the input hot path.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct BindingCatalog<A> {
100    pub bindings: Vec<BindingInfo<A>>,
101}
102
103impl<A> Default for BindingCatalog<A> {
104    fn default() -> Self {
105        Self {
106            bindings: Vec::new(),
107        }
108    }
109}
110
111impl<A: Clone> BindingCatalog<A> {
112    /// Snapshot every binding in `registry`, tagging each with `source`. The
113    /// runtime registry carries no provenance, so the caller states it here.
114    pub fn from_registry(registry: &InputRegistry<A>, source: BindingSource) -> Self {
115        let mut bindings = Vec::new();
116        for map in registry.maps.values() {
117            for (sequence, action) in &map.bindings {
118                bindings.push(BindingInfo {
119                    layer: BindingLayer::Keymap,
120                    mode: map.id.clone(),
121                    sequence: sequence.clone(),
122                    action: action.clone(),
123                    source,
124                });
125            }
126        }
127        Self { bindings }
128    }
129
130    /// Record an additional binding. Used when merging several sources (e.g.
131    /// defaults then config) into one catalog.
132    pub fn push(&mut self, info: BindingInfo<A>) {
133        self.bindings.push(info);
134    }
135
136    /// Merge another catalog's bindings into this one, preserving each
137    /// binding's own layer/mode/source.
138    pub fn extend(&mut self, other: BindingCatalog<A>) {
139        self.bindings.extend(other.bindings);
140    }
141}
142
143impl<A> BindingCatalog<A> {
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    pub fn bindings_for_mode(&self, mode: &str) -> Vec<&BindingInfo<A>> {
149        self.bindings
150            .iter()
151            .filter(|info| info.mode == mode)
152            .collect()
153    }
154
155    pub fn bindings_for_sequence(&self, mode: &str, sequence: &[KeyChord]) -> Vec<&BindingInfo<A>> {
156        self.bindings
157            .iter()
158            .filter(|info| info.mode == mode && info.sequence == sequence)
159            .collect()
160    }
161}
162
163impl<A: PartialEq> BindingCatalog<A> {
164    pub fn bindings_for_action(&self, action: &A) -> Vec<&BindingInfo<A>> {
165        self.bindings
166            .iter()
167            .filter(|info| &info.action == action)
168            .collect()
169    }
170}
171
172/// A bindable action plus the human-facing metadata a remap UI needs: a stable
173/// name, a description, and the modes it makes sense in.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct BindableActionInfo<A> {
176    pub action: A,
177    pub name: &'static str,
178    pub description: &'static str,
179    pub modes: &'static [&'static str],
180}
181
182const NAV_FOCUS_MODES: &[&str] = &["general"];
183const NAV_GLOBAL_MODES: &[&str] = &["global"];
184
185impl<A> BindableActionInfo<A> {
186    /// A bindable action with a stable config `name` (the string it answers to
187    /// in `[keymap.*]`) and a human-facing `description`. It is bindable in
188    /// every mode (`["global"]`) by default; chain [`modes`](Self::modes) to
189    /// scope it.
190    ///
191    /// ```ignore
192    /// BindableActionInfo::new(Action::ToggleSidebar, "toggle_sidebar", "Show/hide the sidebar")
193    /// ```
194    pub fn new(action: A, name: &'static str, description: &'static str) -> Self {
195        Self {
196            action,
197            name,
198            description,
199            modes: NAV_GLOBAL_MODES,
200        }
201    }
202
203    /// Restrict the modes this action may be bound in (default `["global"]`).
204    pub fn modes(mut self, modes: &'static [&'static str]) -> Self {
205        self.modes = modes;
206        self
207    }
208}
209
210/// The built-in [`NavigationAction`](crate::keybindings::NavigationAction)s as
211/// bindable entries, lifted into the application's own action type `A`.
212pub fn navigation_bindable_actions<A>() -> Vec<BindableActionInfo<A>>
213where
214    A: From<crate::keybindings::NavigationAction>,
215{
216    crate::keybindings::NavigationAction::infos()
217        .iter()
218        .map(|info| BindableActionInfo {
219            action: A::from(info.action),
220            name: info.name,
221            description: info.description,
222            modes: match info.category {
223                "Focus" => NAV_FOCUS_MODES,
224                _ => NAV_GLOBAL_MODES,
225            },
226        })
227        .collect()
228}
229
230/// The bindings analysed alongside the conflicts found among them.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct BindingAnalysis<A> {
233    pub bindings: Vec<BindingInfo<A>>,
234    pub conflicts: Vec<BindingConflict<A>>,
235}
236
237/// Find keymap-layer collisions in `catalog`.
238///
239/// - The same sequence bound to two different actions in one mode is a
240///   [`BindingConflict::SameModeDuplicate`].
241/// - The same sequence bound in two simultaneously-active modes is a
242///   [`BindingConflict::ActiveModeShadow`], ordered by `active_modes`: the
243///   earlier mode shadows the later one.
244///
245/// A sequence bound to the *same* action in several places is not a conflict.
246pub fn analyze_keymap_bindings<A>(
247    catalog: &BindingCatalog<A>,
248    active_modes: &[impl AsRef<str>],
249) -> BindingAnalysis<A>
250where
251    A: Clone + PartialEq,
252{
253    let mut conflicts = Vec::new();
254
255    // Same-mode duplicates: group by (mode, sequence), report distinct actions.
256    let mut seen: Vec<(&str, &[KeyChord], &A)> = Vec::new();
257    for info in &catalog.bindings {
258        if info.layer != BindingLayer::Keymap {
259            continue;
260        }
261        if let Some((_, _, existing)) = seen
262            .iter()
263            .find(|(mode, seq, _)| *mode == info.mode.as_str() && *seq == info.sequence.as_slice())
264        {
265            if **existing != info.action {
266                conflicts.push(BindingConflict::SameModeDuplicate {
267                    mode: info.mode.clone(),
268                    sequence: info.sequence.clone(),
269                    first: (*existing).clone(),
270                    second: info.action.clone(),
271                });
272            }
273        } else {
274            seen.push((info.mode.as_str(), info.sequence.as_slice(), &info.action));
275        }
276    }
277
278    // Active-mode shadowing: for each sequence, walk active modes in priority
279    // order and report each later binding the earlier one shadows.
280    // Shadowing groups purely by sequence across the active modes, so we
281    // de-dup by sequence (not by mode) to report each clashing sequence once.
282    let active: Vec<&str> = active_modes.iter().map(|m| m.as_ref()).collect();
283    let mut handled: Vec<&[KeyChord]> = Vec::new();
284    for info in &catalog.bindings {
285        if info.layer != BindingLayer::Keymap || !active.contains(&info.mode.as_str()) {
286            continue;
287        }
288        if handled.iter().any(|seq| *seq == info.sequence.as_slice()) {
289            continue;
290        }
291        handled.push(info.sequence.as_slice());
292
293        // Collect every active-mode binding of this exact sequence, ordered by
294        // the active-mode priority.
295        let mut hits: Vec<&BindingInfo<A>> = active
296            .iter()
297            .filter_map(|mode| {
298                catalog.bindings.iter().find(|other| {
299                    other.layer == BindingLayer::Keymap
300                        && other.mode == *mode
301                        && other.sequence == info.sequence
302                })
303            })
304            .collect();
305        // Stable de-dup keeping priority order.
306        hits.dedup_by(|a, b| a.mode == b.mode);
307
308        if hits.len() < 2 {
309            continue;
310        }
311        let winner = hits[0];
312        for shadowed in &hits[1..] {
313            conflicts.push(BindingConflict::ActiveModeShadow {
314                sequence: info.sequence.clone(),
315                first_mode: winner.mode.clone(),
316                first: winner.action.clone(),
317                shadowed_mode: shadowed.mode.clone(),
318                shadowed: shadowed.action.clone(),
319            });
320        }
321    }
322
323    BindingAnalysis {
324        bindings: catalog.bindings.clone(),
325        conflicts,
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::input::{InputRegistry, KeyMap, try_parse_binding};
333
334    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
335    enum TestAction {
336        A,
337        B,
338    }
339
340    fn seq(binding: &str) -> Vec<KeyChord> {
341        try_parse_binding(binding).unwrap()
342    }
343
344    fn registry() -> InputRegistry<TestAction> {
345        let mut reg = InputRegistry::empty();
346        let mut general = KeyMap::new("general");
347        general.bind(seq("ctrl+a"), TestAction::A);
348        reg.add_map(general);
349        let mut global = KeyMap::new("global");
350        global.bind(seq("ctrl+a"), TestAction::B);
351        reg.add_map(global);
352        reg
353    }
354
355    #[test]
356    fn catalog_records_layer_and_source() {
357        let catalog = BindingCatalog::from_registry(&registry(), BindingSource::Config);
358        assert_eq!(catalog.bindings.len(), 2);
359        assert!(
360            catalog
361                .bindings
362                .iter()
363                .all(|b| b.layer == BindingLayer::Keymap && b.source == BindingSource::Config)
364        );
365        assert_eq!(catalog.bindings_for_mode("general").len(), 1);
366        assert_eq!(catalog.bindings_for_action(&TestAction::A).len(), 1);
367        assert_eq!(
368            catalog
369                .bindings_for_sequence("global", &seq("ctrl+a"))
370                .len(),
371            1
372        );
373    }
374
375    #[test]
376    fn active_mode_shadow_ordered_by_priority() {
377        let catalog = BindingCatalog::from_registry(&registry(), BindingSource::Config);
378        let analysis = analyze_keymap_bindings(&catalog, &["general", "global"]);
379        let shadows: Vec<_> = analysis
380            .conflicts
381            .iter()
382            .filter(|c| matches!(c, BindingConflict::ActiveModeShadow { .. }))
383            .collect();
384        assert_eq!(shadows.len(), 1);
385        match shadows[0] {
386            BindingConflict::ActiveModeShadow {
387                first_mode,
388                shadowed_mode,
389                ..
390            } => {
391                assert_eq!(first_mode, "general");
392                assert_eq!(shadowed_mode, "global");
393            }
394            _ => unreachable!(),
395        }
396    }
397
398    #[test]
399    fn same_action_in_two_modes_is_not_a_duplicate() {
400        let mut reg = InputRegistry::empty();
401        let mut general = KeyMap::new("general");
402        general.bind(seq("ctrl+a"), TestAction::A);
403        reg.add_map(general);
404        let mut global = KeyMap::new("global");
405        global.bind(seq("ctrl+a"), TestAction::A);
406        reg.add_map(global);
407
408        let catalog = BindingCatalog::from_registry(&reg, BindingSource::Builtin);
409        let analysis = analyze_keymap_bindings(&catalog, &["general", "global"]);
410        assert!(
411            !analysis
412                .conflicts
413                .iter()
414                .any(|c| matches!(c, BindingConflict::SameModeDuplicate { .. }))
415        );
416    }
417
418    #[test]
419    fn same_mode_duplicate_detected_when_merged() {
420        // Two sources can disagree on the same (mode, sequence).
421        let mut catalog = BindingCatalog::new();
422        catalog.push(BindingInfo {
423            layer: BindingLayer::Keymap,
424            mode: "nor".to_string(),
425            sequence: seq("d"),
426            action: TestAction::A,
427            source: BindingSource::Builtin,
428        });
429        catalog.push(BindingInfo {
430            layer: BindingLayer::Keymap,
431            mode: "nor".to_string(),
432            sequence: seq("d"),
433            action: TestAction::B,
434            source: BindingSource::Config,
435        });
436        let analysis = analyze_keymap_bindings(&catalog, &["nor"]);
437        assert!(
438            analysis
439                .conflicts
440                .iter()
441                .any(|c| matches!(c, BindingConflict::SameModeDuplicate { .. }))
442        );
443    }
444}