keymap-core 0.1.1

Environment-aware keymap resolution core for terminal UIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! Opt-in rebind validation via virtual overlay.
//!
//! [`validate_rebind`] answers "is it safe to bind `proposed` into `target`?"
//! without cloning any keymap or requiring any bounds on `A`. The full layer
//! stack is inspected once, in a single linear pass, so the check is O(R × L)
//! where R is `reserved.len()` and L is `layers.len()`.

use crate::{KeyInput, Keymap, LegacyForm};

/// Why a proposed rebind would break a reserved key.
///
/// This is `#[non_exhaustive]` because the empirical capability-aware layer
/// may add further break reasons (e.g. "would shadow on kitty protocol") in a
/// future additive release.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BreakReason {
    /// `proposed` is identical to the reserved chord: placing it into `target`
    /// would directly steal that chord from the app's escape hatch.
    DirectSteal,
    /// On a legacy 7-bit (C0) terminal `proposed` is indistinguishable from
    /// the reserved chord (e.g. `ctrl+i` ≡ `tab`): binding `proposed` would
    /// fire on the reserved key on any terminal that does not implement an
    /// enhanced keyboard protocol.
    LegacyCollapse,
}

/// What [`validate_rebind`] concluded.
///
/// This is `#[non_exhaustive]` so that additive verdicts (e.g. an advisory
/// "allowed but unreachable on legacy terminals") can be added without
/// breaking callers. Match both arms with an explicit binding for all known
/// fields so the compiler surfaces any future field additions.
#[derive(Debug)]
#[non_exhaustive]
pub enum RebindVerdict<'a, A> {
    /// Refused. The proposed chord would break the caller's escape hatch.
    BreaksReserved {
        /// The reserved key whose resolution would be stolen.
        reserved: KeyInput,
        /// The structural reason the rebind is refused.
        reason: BreakReason,
    },
    /// Allowed. The rebind does not threaten any reserved key.
    Allowed {
        /// The action that `proposed` currently resolves to via the layer
        /// stack, if any. `Some(&a)` means the rebind would silently override
        /// that action in the target layer — worth surfacing in a UI.
        shadows: Option<&'a A>,
        /// How `proposed` fares on a legacy 7-bit C0 terminal. This is
        /// carried here so the caller can surface the legacy story alongside
        /// the safety verdict in a single call.
        legacy: LegacyForm,
    },
}

/// Validates a proposed rebind of `proposed` into layer `layers[target]`
/// **without mutating** the live keymap or requiring `A: Clone` or `A:
/// PartialEq`.
///
/// ## Semantics
///
/// The contract is strict: **a reserved chord cannot be the target of a
/// rebind, period**. A rebind of the same action onto the same chord is
/// refused just like any other rebind onto a reserved key. This is a
/// deliberate design choice: distinguishing "same action, no-op" from
/// "different action, dangerous" requires `A: PartialEq`, which this
/// function deliberately avoids; and a true no-op rebind is a UI concern
/// that should be filtered before calling this function.
///
/// If you need a more permissive variant (e.g. allowing same-action
/// rebinds), add a *sibling function* with the relaxed contract rather than
/// changing this one. Relaxing an existing function would be a silent
/// behavioural change for callers who rely on the strict semantics; adding a
/// sibling is additive.
///
/// ## Virtual overlay
///
/// No keymap is cloned. For each `reserved` key `r` the function walks the
/// layer slice once:
///
/// - If any layer *before* `target` already has a binding for `r`, that
///   layer would win regardless of what `target` contains, so adding
///   `proposed` to `target` cannot affect `r`'s resolution — the proposed
///   bind is harmless for that reserved key.
/// - Otherwise, if `proposed == r` (direct steal) or
///   `proposed.legacy_form() == CollapsesTo(r)` (legacy collapse), the
///   rebind is refused with the appropriate [`BreakReason`].
///
/// ## Panics
///
/// Panics if `target >= layers.len()`. The caller must guarantee `target`
/// is a valid index into `layers`.
///
/// ## Example
///
/// ```
/// use keymap_core::{Key, KeyInput, Keymap, Modifiers, validate_rebind,
///                   RebindVerdict, BreakReason};
///
/// let esc = KeyInput::new(Key::Esc, Modifiers::NONE);
/// let cs  = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
///
/// let global: Keymap<&str> = Keymap::new();
/// let layers = [&global];
///
/// // ctrl+s is not reserved — allowed.
/// let v = validate_rebind(&layers, 0, cs, &[esc]);
/// assert!(matches!(v, RebindVerdict::Allowed { .. }));
///
/// // Trying to bind Esc — refused.
/// let v = validate_rebind(&layers, 0, esc, &[esc]);
/// assert!(matches!(v, RebindVerdict::BreaksReserved {
///     reason: BreakReason::DirectSteal, ..
/// }));
/// ```
#[must_use]
pub fn validate_rebind<'a, A>(
    layers: &[&'a Keymap<A>],
    target: usize,
    proposed: KeyInput,
    reserved: &[KeyInput],
) -> RebindVerdict<'a, A> {
    assert!(
        target < layers.len(),
        "validate_rebind: target index {target} is out of bounds (layers.len() = {})",
        layers.len(),
    );

    // Pre-compute whether `proposed` legacy-collapses to each reserved key.
    // We compute this once and reuse it per reserved key in the loop below.
    let proposed_legacy = proposed.legacy_form();

    for &r in reserved {
        // Step 1: Is `proposed` a threat to `r` at all?
        //
        // A threat exists when `proposed` and `r` would collide in the
        // target layer:
        //   (a) Direct steal: proposed is exactly r.
        //   (b) Legacy collapse: proposed collapses to r on legacy terminals.
        let break_reason = if proposed == r {
            Some(BreakReason::DirectSteal)
        } else if let LegacyForm::CollapsesTo(twin) = proposed_legacy {
            if twin == r {
                Some(BreakReason::LegacyCollapse)
            } else {
                None
            }
        } else {
            None
        };

        let Some(reason) = break_reason else {
            // `proposed` does not collide with `r`; move on.
            continue;
        };

        // Step 2: Would `r` even reach `target`?
        //
        // Walk the layers *before* `target`. If any of them already has a
        // binding for `r`, that layer wins the resolution regardless of what
        // `target` contains — putting `proposed` into `target` cannot change
        // how `r` resolves, so this reserved key is safe.
        let already_shadowed = layers[..target].iter().any(|l| l.contains(&r));
        if already_shadowed {
            continue;
        }

        // `proposed` collides with `r` and would reach `target` — refuse.
        return RebindVerdict::BreaksReserved {
            reserved: r,
            reason,
        };
    }

    // No reserved key is threatened. Report what `proposed` currently
    // resolves to in the existing stack (the optional advisory shadow).
    let shadows = layers.iter().find_map(|l| l.get(&proposed));

    RebindVerdict::Allowed {
        shadows,
        legacy: proposed_legacy,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Key, KeyInput, Keymap, Modifiers};

    fn ctrl(c: char) -> KeyInput {
        KeyInput::new(Key::Char(c), Modifiers::CTRL)
    }

    fn plain(c: char) -> KeyInput {
        KeyInput::new(Key::Char(c), Modifiers::NONE)
    }

    fn esc() -> KeyInput {
        KeyInput::new(Key::Esc, Modifiers::NONE)
    }

    fn tab() -> KeyInput {
        KeyInput::new(Key::Tab, Modifiers::NONE)
    }

    // ─────────────────────────────────────────────────────
    // ① Direct steal: proposed == reserved
    // ─────────────────────────────────────────────────────

    #[test]
    fn direct_steal_of_reserved_is_refused() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let v = validate_rebind(&layers, 0, esc(), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reserved,
                    reason: BreakReason::DirectSteal
                } if reserved == esc()
            ),
            "expected DirectSteal for esc"
        );
    }

    #[test]
    fn direct_steal_is_refused_regardless_of_which_reserved_matches() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let reserved = [esc(), ctrl('c')];
        // Trying to bind ctrl+c (the second reserved key).
        let v = validate_rebind(&layers, 0, ctrl('c'), &reserved);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reason: BreakReason::DirectSteal,
                    ..
                }
            ),
            "ctrl+c is reserved, expected DirectSteal"
        );
    }

    // ─────────────────────────────────────────────────────
    // ② Upper layer already steals reserved → proposed is harmless
    // ─────────────────────────────────────────────────────

    #[test]
    fn upper_layer_already_steals_reserved_proposed_is_harmless() {
        // `esc` is reserved. The overlay (layer 0) binds esc already, so
        // putting anything onto layer 1 (global) cannot change how `esc`
        // resolves — allowed.
        let mut overlay: Keymap<&str> = Keymap::new();
        overlay.bind(esc(), "overlay_escape_handler");
        let mut global: Keymap<&str> = Keymap::new();
        global.bind(plain('j'), "cursor_down");

        let layers = [&overlay, &global];
        // Bind esc into layer 1 (global). Layer 0 (overlay) already claims it.
        let v = validate_rebind(&layers, 1, esc(), &[esc()]);
        assert!(
            matches!(v, RebindVerdict::Allowed { .. }),
            "upper layer shadows esc already; target bind is harmless"
        );
    }

    #[test]
    fn upper_layer_shadow_does_not_apply_to_target_zero() {
        // When target is 0 there are no layers before it, so no existing
        // layer can shadow `r` — the steal is always direct.
        let mut overlay: Keymap<&str> = Keymap::new();
        overlay.bind(ctrl('c'), "existing");
        let global: Keymap<&str> = Keymap::new();
        let layers = [&overlay, &global];

        // Bind ctrl+c into layer 0 (which already has it). No prior layer
        // can shadow it — refused.
        let v = validate_rebind(&layers, 0, ctrl('c'), &[ctrl('c')]);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reason: BreakReason::DirectSteal,
                    ..
                }
            ),
            "target=0 means no prior layer can shield reserved"
        );
    }

    // ─────────────────────────────────────────────────────
    // ③ Harmless bind to lower layer, with shadows advisory
    // ─────────────────────────────────────────────────────

    #[test]
    fn bind_onto_unbound_chord_is_allowed_no_shadow() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
        assert!(
            matches!(v, RebindVerdict::Allowed { shadows: None, .. }),
            "ctrl+s is not reserved and not yet bound"
        );
    }

    #[test]
    fn bind_onto_existing_chord_reports_shadow() {
        let mut global: Keymap<&str> = Keymap::new();
        global.bind(plain('j'), "cursor_down");
        let layers = [&global];
        let v = validate_rebind(&layers, 0, plain('j'), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::Allowed {
                    shadows: Some(&"cursor_down"),
                    ..
                }
            ),
            "j has an existing binding that would be shadowed"
        );
    }

    #[test]
    fn shadow_is_read_from_any_layer_not_just_target() {
        // `ctrl+s` is bound in the inner layer (index 1), not in the overlay.
        // validate_rebind should still see it as a shadow when we bind onto
        // the overlay (index 0), because the shadow lookup walks the whole stack.
        let overlay: Keymap<&str> = Keymap::new();
        let mut global: Keymap<&str> = Keymap::new();
        global.bind(ctrl('s'), "save");
        let layers = [&overlay, &global];
        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::Allowed {
                    shadows: Some(&"save"),
                    ..
                }
            ),
            "shadow comes from inner layer"
        );
    }

    // ─────────────────────────────────────────────────────
    // ④ Legacy collapse: ctrl+i collapses to tab
    // ─────────────────────────────────────────────────────

    #[test]
    fn legacy_collapse_onto_reserved_is_refused() {
        // `tab` is reserved. `ctrl+i` collapses to `tab` on legacy terminals.
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
        let v = validate_rebind(&layers, 0, ctrl_i, &[tab()]);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reserved,
                    reason: BreakReason::LegacyCollapse,
                } if reserved == tab()
            ),
            "ctrl+i collapses to tab on legacy terminals — must be refused"
        );
    }

    #[test]
    fn legacy_collapse_is_harmless_when_twin_is_not_reserved() {
        // `ctrl+i` collapses to `tab`, but `tab` is not in our reserved set.
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
        let v = validate_rebind(&layers, 0, ctrl_i, &[esc()]);
        assert!(
            matches!(v, RebindVerdict::Allowed { .. }),
            "collapse target not in reserved set → allowed"
        );
    }

    #[test]
    fn legacy_collapse_shielded_by_upper_layer_is_harmless() {
        // Tab is reserved but the overlay already claims it, so binding
        // ctrl+i into the global layer cannot change tab's resolution.
        let mut overlay: Keymap<&str> = Keymap::new();
        overlay.bind(tab(), "tab_handler");
        let global: Keymap<&str> = Keymap::new();
        let layers = [&overlay, &global];
        let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
        let v = validate_rebind(&layers, 1, ctrl_i, &[tab()]);
        assert!(
            matches!(v, RebindVerdict::Allowed { .. }),
            "upper layer shields tab from legacy collapse in lower layer"
        );
    }

    // ─────────────────────────────────────────────────────
    // ⑤ Same-action rebind is STILL refused (strict semantics, spec-fixed)
    // ─────────────────────────────────────────────────────

    #[test]
    fn same_action_rebind_onto_reserved_is_refused() {
        // The strict contract: even a "no-op" rebind of esc → same action is
        // refused because validate_rebind has no A: PartialEq bound and
        // intentionally does not compare actions. This test pins the spec.
        let mut global: Keymap<&str> = Keymap::new();
        global.bind(esc(), "quit");
        let layers = [&global];
        let v = validate_rebind(&layers, 0, esc(), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reason: BreakReason::DirectSteal,
                    ..
                }
            ),
            "same-action rebind onto reserved is still refused (strict contract)"
        );
    }

    // ─────────────────────────────────────────────────────
    // ⑥ target out of bounds → should_panic
    // ─────────────────────────────────────────────────────

    #[test]
    #[should_panic(expected = "target index 1 is out of bounds")]
    fn target_out_of_bounds_panics() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let _ = validate_rebind(&layers, 1, esc(), &[esc()]);
    }

    #[test]
    #[should_panic(expected = "target index 0 is out of bounds")]
    fn empty_layers_panics() {
        let layers: &[&Keymap<&str>] = &[];
        let _ = validate_rebind(layers, 0, esc(), &[]);
    }

    // ─────────────────────────────────────────────────────
    // Additional edge cases
    // ─────────────────────────────────────────────────────

    #[test]
    fn empty_reserved_set_is_always_allowed() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        let v = validate_rebind(&layers, 0, esc(), &[]);
        assert!(
            matches!(v, RebindVerdict::Allowed { .. }),
            "no reserved keys means everything is allowed"
        );
    }

    #[test]
    fn allowed_carries_correct_legacy_form_for_proposed() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        // ctrl+s is representable.
        let v = validate_rebind(&layers, 0, ctrl('s'), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::Allowed {
                    legacy: LegacyForm::Representable,
                    ..
                }
            ),
            "ctrl+s is representable on legacy terminals"
        );
    }

    #[test]
    fn allowed_carries_collapses_to_legacy_form_when_not_reserved() {
        let global: Keymap<&str> = Keymap::new();
        let layers = [&global];
        // ctrl+shift+s collapses to ctrl+s; tab is not reserved.
        let ctrl_shift_s = KeyInput::new(Key::Char('s'), Modifiers::CTRL | Modifiers::SHIFT);
        let v = validate_rebind(&layers, 0, ctrl_shift_s, &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::Allowed {
                    legacy: LegacyForm::CollapsesTo(twin),
                    ..
                } if twin == ctrl('s')
            ),
            "ctrl+shift+s collapses to ctrl+s and should be reflected in Allowed"
        );
    }

    #[test]
    fn multi_layer_three_layers_target_middle() {
        // Three layers: [overlay(idx 0), mid(idx 1), global(idx 2)].
        // esc is reserved. Binding esc into mid (idx 1) is refused because
        // no layer before index 1 (only overlay) claims esc, so esc would
        // reach target=1.
        let overlay: Keymap<&str> = Keymap::new();
        let mid: Keymap<&str> = Keymap::new();
        let global: Keymap<&str> = Keymap::new();
        let layers = [&overlay, &mid, &global];
        let v = validate_rebind(&layers, 1, esc(), &[esc()]);
        assert!(
            matches!(
                v,
                RebindVerdict::BreaksReserved {
                    reason: BreakReason::DirectSteal,
                    ..
                }
            ),
            "no prior layer shields esc; mid target is refused"
        );
    }

    #[test]
    fn multi_layer_overlay_shields_for_target_two() {
        // Same setup, but overlay (idx 0) now binds esc.
        // Binding esc into global (idx 2) is harmless.
        let mut overlay: Keymap<&str> = Keymap::new();
        overlay.bind(esc(), "overlay_esc");
        let mid: Keymap<&str> = Keymap::new();
        let global: Keymap<&str> = Keymap::new();
        let layers = [&overlay, &mid, &global];
        let v = validate_rebind(&layers, 2, esc(), &[esc()]);
        assert!(
            matches!(v, RebindVerdict::Allowed { .. }),
            "overlay at idx 0 shields esc from target idx 2"
        );
    }
}