Skip to main content

keymap_core/
rebind.rs

1//! Opt-in rebind validation via virtual overlay.
2//!
3//! [`validate_rebind`] answers "is it safe to bind `proposed` into `target`?"
4//! without cloning any keymap or requiring any bounds on `A`. The full layer
5//! stack is inspected once, in a single linear pass, so the check is O(R × L)
6//! where R is `reserved.len()` and L is `layers.len()`.
7
8use crate::{KeyInput, Keymap, LegacyForm};
9
10/// Why a proposed rebind would break a reserved key.
11///
12/// This is `#[non_exhaustive]` because the empirical capability-aware layer
13/// may add further break reasons (e.g. "would shadow on kitty protocol") in a
14/// future additive release.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum BreakReason {
18    /// `proposed` is identical to the reserved chord: placing it into `target`
19    /// would directly steal that chord from the app's escape hatch.
20    DirectSteal,
21    /// On a legacy 7-bit (C0) terminal `proposed` is indistinguishable from
22    /// the reserved chord (e.g. `ctrl+i` ≡ `tab`): binding `proposed` would
23    /// fire on the reserved key on any terminal that does not implement an
24    /// enhanced keyboard protocol.
25    LegacyCollapse,
26}
27
28/// What [`validate_rebind`] concluded.
29///
30/// This is `#[non_exhaustive]` so that additive verdicts (e.g. an advisory
31/// "allowed but unreachable on legacy terminals") can be added without
32/// breaking callers. Match both arms with an explicit binding for all known
33/// fields so the compiler surfaces any future field additions.
34#[derive(Debug)]
35#[non_exhaustive]
36pub enum RebindVerdict<'a, A> {
37    /// Refused. The proposed chord would break the caller's escape hatch.
38    BreaksReserved {
39        /// The reserved key whose resolution would be stolen.
40        reserved: KeyInput,
41        /// The structural reason the rebind is refused.
42        reason: BreakReason,
43    },
44    /// Allowed. The rebind does not threaten any reserved key.
45    Allowed {
46        /// The action that `proposed` currently resolves to via the layer
47        /// stack, if any. `Some(&a)` means the rebind would silently override
48        /// that action in the target layer — worth surfacing in a UI.
49        shadows: Option<&'a A>,
50        /// How `proposed` fares on a legacy 7-bit C0 terminal. This is
51        /// carried here so the caller can surface the legacy story alongside
52        /// the safety verdict in a single call.
53        legacy: LegacyForm,
54    },
55}
56
57/// Validates a proposed rebind of `proposed` into layer `layers[target]`
58/// **without mutating** the live keymap or requiring `A: Clone` or `A:
59/// PartialEq`.
60///
61/// ## Semantics
62///
63/// The contract is strict: **a reserved chord cannot be the target of a
64/// rebind, period**. A rebind of the same action onto the same chord is
65/// refused just like any other rebind onto a reserved key. This is a
66/// deliberate design choice: distinguishing "same action, no-op" from
67/// "different action, dangerous" requires `A: PartialEq`, which this
68/// function deliberately avoids; and a true no-op rebind is a UI concern
69/// that should be filtered before calling this function.
70///
71/// If you need a more permissive variant (e.g. allowing same-action
72/// rebinds), add a *sibling function* with the relaxed contract rather than
73/// changing this one. Relaxing an existing function would be a silent
74/// behavioural change for callers who rely on the strict semantics; adding a
75/// sibling is additive.
76///
77/// ## Virtual overlay
78///
79/// No keymap is cloned. For each `reserved` key `r` the function walks the
80/// layer slice once:
81///
82/// - If any layer *before* `target` already has a binding for `r`, that
83///   layer would win regardless of what `target` contains, so adding
84///   `proposed` to `target` cannot affect `r`'s resolution — the proposed
85///   bind is harmless for that reserved key.
86/// - Otherwise, if `proposed == r` (direct steal) or
87///   `proposed.legacy_form() == CollapsesTo(r)` (legacy collapse), the
88///   rebind is refused with the appropriate [`BreakReason`].
89///
90/// ## Panics
91///
92/// Panics if `target >= layers.len()`. The caller must guarantee `target`
93/// is a valid index into `layers`.
94///
95/// ## Example
96///
97/// ```
98/// use keymap_core::{Key, KeyInput, Keymap, Modifiers, validate_rebind,
99///                   RebindVerdict, BreakReason};
100///
101/// let esc = KeyInput::new(Key::Esc, Modifiers::NONE);
102/// let cs  = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
103///
104/// let global: Keymap<&str> = Keymap::new();
105/// let layers = [&global];
106///
107/// // ctrl+s is not reserved — allowed.
108/// let v = validate_rebind(&layers, 0, cs, &[esc]);
109/// assert!(matches!(v, RebindVerdict::Allowed { .. }));
110///
111/// // Trying to bind Esc — refused.
112/// let v = validate_rebind(&layers, 0, esc, &[esc]);
113/// assert!(matches!(v, RebindVerdict::BreaksReserved {
114///     reason: BreakReason::DirectSteal, ..
115/// }));
116/// ```
117#[must_use]
118pub fn validate_rebind<'a, A>(
119    layers: &[&'a Keymap<A>],
120    target: usize,
121    proposed: KeyInput,
122    reserved: &[KeyInput],
123) -> RebindVerdict<'a, A> {
124    assert!(
125        target < layers.len(),
126        "validate_rebind: target index {target} is out of bounds (layers.len() = {})",
127        layers.len(),
128    );
129
130    // Pre-compute whether `proposed` legacy-collapses to each reserved key.
131    // We compute this once and reuse it per reserved key in the loop below.
132    let proposed_legacy = proposed.legacy_form();
133
134    for &r in reserved {
135        // Step 1: Is `proposed` a threat to `r` at all?
136        //
137        // A threat exists when `proposed` and `r` would collide in the
138        // target layer:
139        //   (a) Direct steal: proposed is exactly r.
140        //   (b) Legacy collapse: proposed collapses to r on legacy terminals.
141        let break_reason = if proposed == r {
142            Some(BreakReason::DirectSteal)
143        } else if let LegacyForm::CollapsesTo(twin) = proposed_legacy {
144            if twin == r {
145                Some(BreakReason::LegacyCollapse)
146            } else {
147                None
148            }
149        } else {
150            None
151        };
152
153        let Some(reason) = break_reason else {
154            // `proposed` does not collide with `r`; move on.
155            continue;
156        };
157
158        // Step 2: Would `r` even reach `target`?
159        //
160        // Walk the layers *before* `target`. If any of them already has a
161        // binding for `r`, that layer wins the resolution regardless of what
162        // `target` contains — putting `proposed` into `target` cannot change
163        // how `r` resolves, so this reserved key is safe.
164        let already_shadowed = layers[..target].iter().any(|l| l.contains(&r));
165        if already_shadowed {
166            continue;
167        }
168
169        // `proposed` collides with `r` and would reach `target` — refuse.
170        return RebindVerdict::BreaksReserved {
171            reserved: r,
172            reason,
173        };
174    }
175
176    // No reserved key is threatened. Report what `proposed` currently
177    // resolves to in the existing stack (the optional advisory shadow).
178    let shadows = layers.iter().find_map(|l| l.get(&proposed));
179
180    RebindVerdict::Allowed {
181        shadows,
182        legacy: proposed_legacy,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::{Key, KeyInput, Keymap, Modifiers};
190
191    fn ctrl(c: char) -> KeyInput {
192        KeyInput::new(Key::Char(c), Modifiers::CTRL)
193    }
194
195    fn plain(c: char) -> KeyInput {
196        KeyInput::new(Key::Char(c), Modifiers::NONE)
197    }
198
199    fn esc() -> KeyInput {
200        KeyInput::new(Key::Esc, Modifiers::NONE)
201    }
202
203    fn tab() -> KeyInput {
204        KeyInput::new(Key::Tab, Modifiers::NONE)
205    }
206
207    // ─────────────────────────────────────────────────────
208    // ① Direct steal: proposed == reserved
209    // ─────────────────────────────────────────────────────
210
211    #[test]
212    fn direct_steal_of_reserved_is_refused() {
213        let global: Keymap<&str> = Keymap::new();
214        let layers = [&global];
215        let v = validate_rebind(&layers, 0, esc(), &[esc()]);
216        assert!(
217            matches!(
218                v,
219                RebindVerdict::BreaksReserved {
220                    reserved,
221                    reason: BreakReason::DirectSteal
222                } if reserved == esc()
223            ),
224            "expected DirectSteal for esc"
225        );
226    }
227
228    #[test]
229    fn direct_steal_is_refused_regardless_of_which_reserved_matches() {
230        let global: Keymap<&str> = Keymap::new();
231        let layers = [&global];
232        let reserved = [esc(), ctrl('c')];
233        // Trying to bind ctrl+c (the second reserved key).
234        let v = validate_rebind(&layers, 0, ctrl('c'), &reserved);
235        assert!(
236            matches!(
237                v,
238                RebindVerdict::BreaksReserved {
239                    reason: BreakReason::DirectSteal,
240                    ..
241                }
242            ),
243            "ctrl+c is reserved, expected DirectSteal"
244        );
245    }
246
247    // ─────────────────────────────────────────────────────
248    // ② Upper layer already steals reserved → proposed is harmless
249    // ─────────────────────────────────────────────────────
250
251    #[test]
252    fn upper_layer_already_steals_reserved_proposed_is_harmless() {
253        // `esc` is reserved. The overlay (layer 0) binds esc already, so
254        // putting anything onto layer 1 (global) cannot change how `esc`
255        // resolves — allowed.
256        let mut overlay: Keymap<&str> = Keymap::new();
257        overlay.bind(esc(), "overlay_escape_handler");
258        let mut global: Keymap<&str> = Keymap::new();
259        global.bind(plain('j'), "cursor_down");
260
261        let layers = [&overlay, &global];
262        // Bind esc into layer 1 (global). Layer 0 (overlay) already claims it.
263        let v = validate_rebind(&layers, 1, esc(), &[esc()]);
264        assert!(
265            matches!(v, RebindVerdict::Allowed { .. }),
266            "upper layer shadows esc already; target bind is harmless"
267        );
268    }
269
270    #[test]
271    fn upper_layer_shadow_does_not_apply_to_target_zero() {
272        // When target is 0 there are no layers before it, so no existing
273        // layer can shadow `r` — the steal is always direct.
274        let mut overlay: Keymap<&str> = Keymap::new();
275        overlay.bind(ctrl('c'), "existing");
276        let global: Keymap<&str> = Keymap::new();
277        let layers = [&overlay, &global];
278
279        // Bind ctrl+c into layer 0 (which already has it). No prior layer
280        // can shadow it — refused.
281        let v = validate_rebind(&layers, 0, ctrl('c'), &[ctrl('c')]);
282        assert!(
283            matches!(
284                v,
285                RebindVerdict::BreaksReserved {
286                    reason: BreakReason::DirectSteal,
287                    ..
288                }
289            ),
290            "target=0 means no prior layer can shield reserved"
291        );
292    }
293
294    // ─────────────────────────────────────────────────────
295    // ③ Harmless bind to lower layer, with shadows advisory
296    // ─────────────────────────────────────────────────────
297
298    #[test]
299    fn bind_onto_unbound_chord_is_allowed_no_shadow() {
300        let global: Keymap<&str> = Keymap::new();
301        let layers = [&global];
302        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
303        assert!(
304            matches!(v, RebindVerdict::Allowed { shadows: None, .. }),
305            "ctrl+s is not reserved and not yet bound"
306        );
307    }
308
309    #[test]
310    fn bind_onto_existing_chord_reports_shadow() {
311        let mut global: Keymap<&str> = Keymap::new();
312        global.bind(plain('j'), "cursor_down");
313        let layers = [&global];
314        let v = validate_rebind(&layers, 0, plain('j'), &[esc()]);
315        assert!(
316            matches!(
317                v,
318                RebindVerdict::Allowed {
319                    shadows: Some(&"cursor_down"),
320                    ..
321                }
322            ),
323            "j has an existing binding that would be shadowed"
324        );
325    }
326
327    #[test]
328    fn shadow_is_read_from_any_layer_not_just_target() {
329        // `ctrl+s` is bound in the inner layer (index 1), not in the overlay.
330        // validate_rebind should still see it as a shadow when we bind onto
331        // the overlay (index 0), because the shadow lookup walks the whole stack.
332        let overlay: Keymap<&str> = Keymap::new();
333        let mut global: Keymap<&str> = Keymap::new();
334        global.bind(ctrl('s'), "save");
335        let layers = [&overlay, &global];
336        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
337        assert!(
338            matches!(
339                v,
340                RebindVerdict::Allowed {
341                    shadows: Some(&"save"),
342                    ..
343                }
344            ),
345            "shadow comes from inner layer"
346        );
347    }
348
349    // ─────────────────────────────────────────────────────
350    // ④ Legacy collapse: ctrl+i collapses to tab
351    // ─────────────────────────────────────────────────────
352
353    #[test]
354    fn legacy_collapse_onto_reserved_is_refused() {
355        // `tab` is reserved. `ctrl+i` collapses to `tab` on legacy terminals.
356        let global: Keymap<&str> = Keymap::new();
357        let layers = [&global];
358        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
359        let v = validate_rebind(&layers, 0, ctrl_i, &[tab()]);
360        assert!(
361            matches!(
362                v,
363                RebindVerdict::BreaksReserved {
364                    reserved,
365                    reason: BreakReason::LegacyCollapse,
366                } if reserved == tab()
367            ),
368            "ctrl+i collapses to tab on legacy terminals — must be refused"
369        );
370    }
371
372    #[test]
373    fn legacy_collapse_is_harmless_when_twin_is_not_reserved() {
374        // `ctrl+i` collapses to `tab`, but `tab` is not in our reserved set.
375        let global: Keymap<&str> = Keymap::new();
376        let layers = [&global];
377        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
378        let v = validate_rebind(&layers, 0, ctrl_i, &[esc()]);
379        assert!(
380            matches!(v, RebindVerdict::Allowed { .. }),
381            "collapse target not in reserved set → allowed"
382        );
383    }
384
385    #[test]
386    fn legacy_collapse_shielded_by_upper_layer_is_harmless() {
387        // Tab is reserved but the overlay already claims it, so binding
388        // ctrl+i into the global layer cannot change tab's resolution.
389        let mut overlay: Keymap<&str> = Keymap::new();
390        overlay.bind(tab(), "tab_handler");
391        let global: Keymap<&str> = Keymap::new();
392        let layers = [&overlay, &global];
393        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
394        let v = validate_rebind(&layers, 1, ctrl_i, &[tab()]);
395        assert!(
396            matches!(v, RebindVerdict::Allowed { .. }),
397            "upper layer shields tab from legacy collapse in lower layer"
398        );
399    }
400
401    // ─────────────────────────────────────────────────────
402    // ⑤ Same-action rebind is STILL refused (strict semantics, spec-fixed)
403    // ─────────────────────────────────────────────────────
404
405    #[test]
406    fn same_action_rebind_onto_reserved_is_refused() {
407        // The strict contract: even a "no-op" rebind of esc → same action is
408        // refused because validate_rebind has no A: PartialEq bound and
409        // intentionally does not compare actions. This test pins the spec.
410        let mut global: Keymap<&str> = Keymap::new();
411        global.bind(esc(), "quit");
412        let layers = [&global];
413        let v = validate_rebind(&layers, 0, esc(), &[esc()]);
414        assert!(
415            matches!(
416                v,
417                RebindVerdict::BreaksReserved {
418                    reason: BreakReason::DirectSteal,
419                    ..
420                }
421            ),
422            "same-action rebind onto reserved is still refused (strict contract)"
423        );
424    }
425
426    // ─────────────────────────────────────────────────────
427    // ⑥ target out of bounds → should_panic
428    // ─────────────────────────────────────────────────────
429
430    #[test]
431    #[should_panic(expected = "target index 1 is out of bounds")]
432    fn target_out_of_bounds_panics() {
433        let global: Keymap<&str> = Keymap::new();
434        let layers = [&global];
435        let _ = validate_rebind(&layers, 1, esc(), &[esc()]);
436    }
437
438    #[test]
439    #[should_panic(expected = "target index 0 is out of bounds")]
440    fn empty_layers_panics() {
441        let layers: &[&Keymap<&str>] = &[];
442        let _ = validate_rebind(layers, 0, esc(), &[]);
443    }
444
445    // ─────────────────────────────────────────────────────
446    // Additional edge cases
447    // ─────────────────────────────────────────────────────
448
449    #[test]
450    fn empty_reserved_set_is_always_allowed() {
451        let global: Keymap<&str> = Keymap::new();
452        let layers = [&global];
453        let v = validate_rebind(&layers, 0, esc(), &[]);
454        assert!(
455            matches!(v, RebindVerdict::Allowed { .. }),
456            "no reserved keys means everything is allowed"
457        );
458    }
459
460    #[test]
461    fn allowed_carries_correct_legacy_form_for_proposed() {
462        let global: Keymap<&str> = Keymap::new();
463        let layers = [&global];
464        // ctrl+s is representable.
465        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
466        assert!(
467            matches!(
468                v,
469                RebindVerdict::Allowed {
470                    legacy: LegacyForm::Representable,
471                    ..
472                }
473            ),
474            "ctrl+s is representable on legacy terminals"
475        );
476    }
477
478    #[test]
479    fn allowed_carries_collapses_to_legacy_form_when_not_reserved() {
480        let global: Keymap<&str> = Keymap::new();
481        let layers = [&global];
482        // ctrl+shift+s collapses to ctrl+s; tab is not reserved.
483        let ctrl_shift_s = KeyInput::new(Key::Char('s'), Modifiers::CTRL | Modifiers::SHIFT);
484        let v = validate_rebind(&layers, 0, ctrl_shift_s, &[esc()]);
485        assert!(
486            matches!(
487                v,
488                RebindVerdict::Allowed {
489                    legacy: LegacyForm::CollapsesTo(twin),
490                    ..
491                } if twin == ctrl('s')
492            ),
493            "ctrl+shift+s collapses to ctrl+s and should be reflected in Allowed"
494        );
495    }
496
497    #[test]
498    fn multi_layer_three_layers_target_middle() {
499        // Three layers: [overlay(idx 0), mid(idx 1), global(idx 2)].
500        // esc is reserved. Binding esc into mid (idx 1) is refused because
501        // no layer before index 1 (only overlay) claims esc, so esc would
502        // reach target=1.
503        let overlay: Keymap<&str> = Keymap::new();
504        let mid: Keymap<&str> = Keymap::new();
505        let global: Keymap<&str> = Keymap::new();
506        let layers = [&overlay, &mid, &global];
507        let v = validate_rebind(&layers, 1, esc(), &[esc()]);
508        assert!(
509            matches!(
510                v,
511                RebindVerdict::BreaksReserved {
512                    reason: BreakReason::DirectSteal,
513                    ..
514                }
515            ),
516            "no prior layer shields esc; mid target is refused"
517        );
518    }
519
520    #[test]
521    fn multi_layer_overlay_shields_for_target_two() {
522        // Same setup, but overlay (idx 0) now binds esc.
523        // Binding esc into global (idx 2) is harmless.
524        let mut overlay: Keymap<&str> = Keymap::new();
525        overlay.bind(esc(), "overlay_esc");
526        let mid: Keymap<&str> = Keymap::new();
527        let global: Keymap<&str> = Keymap::new();
528        let layers = [&overlay, &mid, &global];
529        let v = validate_rebind(&layers, 2, esc(), &[esc()]);
530        assert!(
531            matches!(v, RebindVerdict::Allowed { .. }),
532            "overlay at idx 0 shields esc from target idx 2"
533        );
534    }
535}