use crate::{KeyInput, Keymap, LegacyForm};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BreakReason {
DirectSteal,
LegacyCollapse,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RebindVerdict<'a, A> {
BreaksReserved {
reserved: KeyInput,
reason: BreakReason,
},
Allowed {
shadows: Option<&'a A>,
legacy: LegacyForm,
},
}
#[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(),
);
let proposed_legacy = proposed.legacy_form();
for &r in reserved {
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 {
continue;
};
let already_shadowed = layers[..target].iter().any(|l| l.contains(&r));
if already_shadowed {
continue;
}
return RebindVerdict::BreaksReserved {
reserved: r,
reason,
};
}
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)
}
#[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')];
let v = validate_rebind(&layers, 0, ctrl('c'), &reserved);
assert!(
matches!(
v,
RebindVerdict::BreaksReserved {
reason: BreakReason::DirectSteal,
..
}
),
"ctrl+c is reserved, expected DirectSteal"
);
}
#[test]
fn upper_layer_already_steals_reserved_proposed_is_harmless() {
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];
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() {
let mut overlay: Keymap<&str> = Keymap::new();
overlay.bind(ctrl('c'), "existing");
let global: Keymap<&str> = Keymap::new();
let layers = [&overlay, &global];
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"
);
}
#[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() {
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"
);
}
#[test]
fn legacy_collapse_onto_reserved_is_refused() {
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() {
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() {
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"
);
}
#[test]
fn same_action_rebind_onto_reserved_is_refused() {
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)"
);
}
#[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(), &[]);
}
#[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];
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];
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() {
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() {
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"
);
}
}