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}