use std::collections::{BTreeMap, HashMap};
use std::fmt;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
SplitWindowH,
SplitWindowV,
KillPane,
NewWindow { name: Option<String> },
RenameWindow,
KillWindow,
SelectWindow { index: usize },
NextWindow,
PreviousWindow,
SelectPane { dir: Dir },
ResizePane { dir: Dir, amount: u16 },
SwapPane { up: bool },
Equalize,
SelectLayout { name: String },
DetachSession,
KillSession,
CopyMode,
Cancel,
BeginSelection,
CopySelectionAndCancel,
ReloadConfig,
CommandPrompt,
ToggleSettings,
ToggleBroadcast,
DisplayMessage { text: String },
SetOption { key: String, value: String },
SetBuffer { name: String, value: String },
PasteBuffer { name: Option<String> },
ListBuffers,
}
impl Action {
#[allow(dead_code)]
pub fn kind(&self) -> &'static str {
match self {
Action::SplitWindowH => "split-window-h",
Action::SplitWindowV => "split-window-v",
Action::KillPane => "kill-pane",
Action::NewWindow { .. } => "new-window",
Action::RenameWindow => "rename-window",
Action::KillWindow => "kill-window",
Action::SelectWindow { .. } => "select-window",
Action::NextWindow => "next-window",
Action::PreviousWindow => "previous-window",
Action::SelectPane { .. } => "select-pane",
Action::ResizePane { .. } => "resize-pane",
Action::SwapPane { .. } => "swap-pane",
Action::Equalize => "equalize",
Action::SelectLayout { .. } => "select-layout",
Action::DetachSession => "detach-session",
Action::KillSession => "kill-session",
Action::CopyMode => "copy-mode",
Action::Cancel => "cancel",
Action::BeginSelection => "begin-selection",
Action::CopySelectionAndCancel => "copy-selection-and-cancel",
Action::ReloadConfig => "reload-config",
Action::CommandPrompt => "command-prompt",
Action::ToggleSettings => "toggle-settings",
Action::ToggleBroadcast => "toggle-broadcast",
Action::DisplayMessage { .. } => "display-message",
Action::SetOption { .. } => "set-option",
Action::SetBuffer { .. } => "set-buffer",
Action::PasteBuffer { .. } => "paste-buffer",
Action::ListBuffers => "list-buffers",
}
}
pub fn vocabulary() -> &'static [&'static str] {
&[
"split-window-h",
"split-window-v",
"kill-pane",
"new-window",
"rename-window",
"kill-window",
"select-window",
"next-window",
"previous-window",
"select-pane",
"resize-pane",
"swap-pane",
"equalize",
"select-layout",
"detach-session",
"kill-session",
"copy-mode",
"cancel",
"begin-selection",
"copy-selection-and-cancel",
"reload-config",
"command-prompt",
"toggle-settings",
"toggle-broadcast",
"display-message",
"set-option",
"set-buffer",
"paste-buffer",
"list-buffers",
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
fn parse(s: &str) -> Option<Self> {
match s {
"up" | "U" | "u" => Some(Dir::Up),
"down" | "D" | "d" => Some(Dir::Down),
"left" | "L" | "l" => Some(Dir::Left),
"right" | "R" | "r" => Some(Dir::Right),
_ => None,
}
}
}
pub fn parse_action(input: &str) -> Result<Action, ActionParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ActionParseError::Empty);
}
let (head, rest) = match trimmed.split_once(char::is_whitespace) {
Some((h, r)) => (h, r.trim()),
None => (trimmed, ""),
};
match head {
"split-window-h" => Ok(Action::SplitWindowH),
"split-window-v" => Ok(Action::SplitWindowV),
"kill-pane" => Ok(Action::KillPane),
"kill-window" => Ok(Action::KillWindow),
"new-window" => parse_new_window(rest),
"rename-window" => Ok(Action::RenameWindow),
"select-window" => {
let idx: usize = rest
.parse()
.map_err(|_| ActionParseError::InvalidArgument {
action: "select-window",
arg: rest.to_string(),
})?;
Ok(Action::SelectWindow { index: idx })
}
"next-window" => Ok(Action::NextWindow),
"previous-window" => Ok(Action::PreviousWindow),
"select-pane" => {
let dir = Dir::parse(rest).ok_or_else(|| ActionParseError::InvalidArgument {
action: "select-pane",
arg: rest.to_string(),
})?;
Ok(Action::SelectPane { dir })
}
"resize-pane" => parse_resize_pane(rest),
"swap-pane" => match rest {
"up" | "U" | "u" | "" => Ok(Action::SwapPane { up: true }),
"down" | "D" | "d" => Ok(Action::SwapPane { up: false }),
other => Err(ActionParseError::InvalidArgument {
action: "swap-pane",
arg: other.to_string(),
}),
},
"equalize" => Ok(Action::Equalize),
"select-layout" => {
if rest.is_empty() {
return Err(ActionParseError::MissingArgument {
action: "select-layout",
arg: "NAME",
});
}
Ok(Action::SelectLayout {
name: rest.to_string(),
})
}
"detach-session" => Ok(Action::DetachSession),
"kill-session" => Ok(Action::KillSession),
"copy-mode" => Ok(Action::CopyMode),
"cancel" => Ok(Action::Cancel),
"begin-selection" => Ok(Action::BeginSelection),
"copy-selection-and-cancel" => Ok(Action::CopySelectionAndCancel),
"reload-config" => Ok(Action::ReloadConfig),
"command-prompt" => Ok(Action::CommandPrompt),
"toggle-settings" => Ok(Action::ToggleSettings),
"toggle-broadcast" => Ok(Action::ToggleBroadcast),
"display-message" => {
if rest.is_empty() {
return Err(ActionParseError::MissingArgument {
action: "display-message",
arg: "TEXT",
});
}
Ok(Action::DisplayMessage {
text: rest.to_string(),
})
}
"set-option" => {
let (k, v) =
rest.split_once(char::is_whitespace)
.ok_or(ActionParseError::MissingArgument {
action: "set-option",
arg: "VALUE",
})?;
Ok(Action::SetOption {
key: k.to_string(),
value: v.trim().to_string(),
})
}
"set-buffer" => {
if rest.is_empty() {
return Err(ActionParseError::MissingArgument {
action: "set-buffer",
arg: "NAME",
});
}
let (name, value) =
rest.split_once(char::is_whitespace)
.ok_or(ActionParseError::MissingArgument {
action: "set-buffer",
arg: "VALUE",
})?;
Ok(Action::SetBuffer {
name: name.to_string(),
value: value.trim().to_string(),
})
}
"paste-buffer" => {
let name = if rest.is_empty() {
None
} else {
Some(rest.to_string())
};
Ok(Action::PasteBuffer { name })
}
"list-buffers" => Ok(Action::ListBuffers),
other => Err(ActionParseError::UnknownAction(other.to_string())),
}
}
fn parse_new_window(rest: &str) -> Result<Action, ActionParseError> {
if rest.is_empty() {
return Ok(Action::NewWindow { name: None });
}
if let Some(stripped) = rest.strip_prefix("-n") {
let name = stripped.trim();
if name.is_empty() {
return Err(ActionParseError::MissingArgument {
action: "new-window",
arg: "NAME",
});
}
return Ok(Action::NewWindow {
name: Some(name.to_string()),
});
}
Ok(Action::NewWindow {
name: Some(rest.to_string()),
})
}
fn parse_resize_pane(rest: &str) -> Result<Action, ActionParseError> {
let (dir_part, amount_part) = match rest.split_once(char::is_whitespace) {
Some((d, a)) => (d, a.trim()),
None => (rest, ""),
};
let dir = Dir::parse(dir_part).ok_or_else(|| ActionParseError::InvalidArgument {
action: "resize-pane",
arg: dir_part.to_string(),
})?;
let amount: u16 = if amount_part.is_empty() {
1
} else {
amount_part
.parse()
.map_err(|_| ActionParseError::InvalidArgument {
action: "resize-pane",
arg: amount_part.to_string(),
})?
};
Ok(Action::ResizePane { dir, amount })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActionParseError {
Empty,
UnknownAction(String),
MissingArgument {
action: &'static str,
arg: &'static str,
},
InvalidArgument {
action: &'static str,
arg: String,
},
}
impl fmt::Display for ActionParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ActionParseError::Empty => f.write_str("empty action"),
ActionParseError::UnknownAction(name) => write!(f, "unknown action: {name}"),
ActionParseError::MissingArgument { action, arg } => {
write!(f, "{action}: missing argument <{arg}>")
}
ActionParseError::InvalidArgument { action, arg } => {
write!(f, "{action}: invalid argument: {arg}")
}
}
}
}
impl std::error::Error for ActionParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyChord {
pub code: KeyCode,
pub mods: KeyModifiers,
}
impl KeyChord {
pub fn new(code: KeyCode, mods: KeyModifiers) -> Self {
Self { code, mods }
}
pub fn from_event(ev: KeyEvent) -> Self {
let mods = ev.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
let code = match ev.code {
KeyCode::Char(c) if !mods.contains(KeyModifiers::SHIFT) => {
KeyCode::Char(c.to_ascii_lowercase())
}
other => other,
};
Self { code, mods }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyParseError {
Empty,
UnknownNamedKey(String),
#[allow(dead_code)]
InvalidModifier(String),
DanglingModifier,
}
impl fmt::Display for KeyParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyParseError::Empty => f.write_str("empty key spec"),
KeyParseError::UnknownNamedKey(s) => write!(f, "unknown named key: {s}"),
KeyParseError::InvalidModifier(s) => write!(f, "invalid modifier: {s}"),
KeyParseError::DanglingModifier => f.write_str("modifier without a key"),
}
}
}
impl std::error::Error for KeyParseError {}
pub fn parse_chord(input: &str) -> Result<KeyChord, KeyParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(KeyParseError::Empty);
}
let mut mods = KeyModifiers::NONE;
let mut rest = trimmed;
loop {
let bytes = rest.as_bytes();
if bytes.len() >= 2 && bytes[1] == b'-' {
match bytes[0] {
b'C' | b'c' => {
mods |= KeyModifiers::CONTROL;
rest = &rest[2..];
continue;
}
b'M' | b'm' => {
mods |= KeyModifiers::ALT;
rest = &rest[2..];
continue;
}
b'S' | b's' => {
mods |= KeyModifiers::SHIFT;
rest = &rest[2..];
continue;
}
_ => {}
}
}
break;
}
if rest.is_empty() {
return Err(KeyParseError::DanglingModifier);
}
if let Some(num) = rest.strip_prefix(['F', 'f']) {
if let Ok(n) = num.parse::<u8>() {
if (1..=12).contains(&n) {
return Ok(KeyChord::new(KeyCode::F(n), mods));
}
}
}
let code = match rest {
"Enter" | "enter" | "Return" | "return" => KeyCode::Enter,
"Esc" | "esc" | "Escape" | "escape" => KeyCode::Esc,
"Tab" | "tab" => KeyCode::Tab,
"BackTab" | "backtab" => KeyCode::BackTab,
"Backspace" | "backspace" | "BS" => KeyCode::Backspace,
"Delete" | "delete" | "Del" => KeyCode::Delete,
"Insert" | "insert" | "Ins" => KeyCode::Insert,
"Home" | "home" => KeyCode::Home,
"End" | "end" => KeyCode::End,
"PageUp" | "pageup" | "PgUp" => KeyCode::PageUp,
"PageDown" | "pagedown" | "PgDn" | "PgDown" => KeyCode::PageDown,
"Up" | "up" => KeyCode::Up,
"Down" | "down" => KeyCode::Down,
"Left" | "left" => KeyCode::Left,
"Right" | "right" => KeyCode::Right,
"Space" | "space" => KeyCode::Char(' '),
s if s.chars().count() == 1 => {
let c = s.chars().next().unwrap();
let c = if mods.contains(KeyModifiers::SHIFT) {
c
} else {
c.to_ascii_lowercase()
};
KeyCode::Char(c)
}
other => return Err(KeyParseError::UnknownNamedKey(other.to_string())),
};
Ok(KeyChord { code, mods })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum KeymapTable {
Prefix,
Normal,
CopyMode,
}
impl KeymapTable {
pub fn name(self) -> &'static str {
match self {
KeymapTable::Prefix => "prefix",
KeymapTable::Normal => "normal",
KeymapTable::CopyMode => "copy_mode",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"prefix" => Some(KeymapTable::Prefix),
"normal" => Some(KeymapTable::Normal),
"copy_mode" | "copy-mode" => Some(KeymapTable::CopyMode),
_ => None,
}
}
pub fn all() -> &'static [KeymapTable] {
&[
KeymapTable::Prefix,
KeymapTable::Normal,
KeymapTable::CopyMode,
]
}
}
#[derive(Debug, Clone, Default)]
pub struct Keymap {
tables: BTreeMap<KeymapTable, HashMap<KeyChord, Action>>,
}
impl Keymap {
pub fn new() -> Self {
Self::default()
}
pub fn lookup(&self, table: KeymapTable, chord: &KeyChord) -> Option<&Action> {
self.tables.get(&table)?.get(chord)
}
#[allow(dead_code)]
pub fn len(&self, table: KeymapTable) -> usize {
self.tables.get(&table).map(HashMap::len).unwrap_or(0)
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.tables.values().all(HashMap::is_empty)
}
fn table_mut(&mut self, t: KeymapTable) -> &mut HashMap<KeyChord, Action> {
self.tables.entry(t).or_default()
}
pub fn bind(&mut self, table: KeymapTable, chord: KeyChord, action: Action) {
self.table_mut(table).insert(chord, action);
}
pub fn clear(&mut self, table: KeymapTable) {
if let Some(t) = self.tables.get_mut(&table) {
t.clear();
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeymapLoadError {
pub table: String,
pub key: String,
pub message: String,
}
impl fmt::Display for KeymapLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[keymap.{}] {key}: {msg}",
self.table,
key = self.key,
msg = self.message
)
}
}
impl std::error::Error for KeymapLoadError {}
pub fn apply_table(
keymap: &mut Keymap,
table: KeymapTable,
raw: &toml::Table,
) -> Result<Vec<String>, KeymapLoadError> {
let mut warnings = Vec::new();
if let Some(v) = raw.get("clear") {
match v.as_bool() {
Some(true) => keymap.clear(table),
Some(false) => {}
None => {
return Err(KeymapLoadError {
table: table.name().to_string(),
key: "clear".to_string(),
message: "must be a boolean".to_string(),
});
}
}
}
for (key_str, value) in raw {
if key_str == "clear" {
continue;
}
let action_str = match value.as_str() {
Some(s) => s,
None => {
return Err(KeymapLoadError {
table: table.name().to_string(),
key: key_str.clone(),
message: "value must be a string action".to_string(),
});
}
};
let chord = parse_chord(key_str).map_err(|e| KeymapLoadError {
table: table.name().to_string(),
key: key_str.clone(),
message: format!("invalid key: {e}"),
})?;
let action = parse_action(action_str).map_err(|e| KeymapLoadError {
table: table.name().to_string(),
key: key_str.clone(),
message: format!("invalid action: {e}"),
})?;
if keymap.lookup(table, &chord).is_some() {
warnings.push(format!(
"[keymap.{}] {key_str} overrides previous binding",
table.name()
));
}
keymap.bind(table, chord, action);
}
Ok(warnings)
}
pub fn apply_keymap_section(
keymap: &mut Keymap,
raw: &toml::Table,
) -> Result<Vec<String>, KeymapLoadError> {
let mut warnings = Vec::new();
for (sub_name, sub_value) in raw {
let table = KeymapTable::from_str(sub_name).ok_or_else(|| KeymapLoadError {
table: sub_name.clone(),
key: String::new(),
message: format!(
"unknown keymap table (valid: {})",
KeymapTable::all()
.iter()
.map(|t| t.name())
.collect::<Vec<_>>()
.join(", ")
),
})?;
let sub_table = sub_value.as_table().ok_or_else(|| KeymapLoadError {
table: sub_name.clone(),
key: String::new(),
message: "must be a table".to_string(),
})?;
warnings.extend(apply_table(keymap, table, sub_table)?);
}
Ok(warnings)
}
pub const DEFAULT_KEYMAP_TOML: &str = include_str!("../assets/default-keymap.toml");
pub fn load_defaults() -> Keymap {
let raw: toml::Table = toml::from_str(DEFAULT_KEYMAP_TOML)
.expect("default-keymap.toml must parse — this is a build error, not runtime");
let inner = raw
.get("keymap")
.and_then(toml::Value::as_table)
.expect("default-keymap.toml must contain [keymap.*] tables");
let mut keymap = Keymap::new();
apply_keymap_section(&mut keymap, inner)
.expect("default-keymap.toml must validate — this is a build error, not runtime");
keymap
}
#[allow(dead_code)]
#[derive(Debug, Default, Deserialize)]
pub struct RawKeymapSection {
#[serde(flatten)]
pub tables: BTreeMap<String, toml::Table>,
}
#[cfg(test)]
mod tests {
use super::*;
fn chord(code: KeyCode, mods: KeyModifiers) -> KeyChord {
KeyChord::new(code, mods)
}
#[test]
fn parses_simple_actions() {
assert_eq!(parse_action("kill-pane"), Ok(Action::KillPane));
assert_eq!(parse_action("equalize"), Ok(Action::Equalize));
assert_eq!(parse_action("detach-session"), Ok(Action::DetachSession));
assert_eq!(parse_action("reload-config"), Ok(Action::ReloadConfig));
}
#[test]
fn parses_actions_with_args() {
assert_eq!(
parse_action("select-window 0"),
Ok(Action::SelectWindow { index: 0 })
);
assert_eq!(
parse_action("select-pane right"),
Ok(Action::SelectPane { dir: Dir::Right })
);
assert_eq!(
parse_action("resize-pane left 5"),
Ok(Action::ResizePane {
dir: Dir::Left,
amount: 5
})
);
assert_eq!(
parse_action("new-window -n logs"),
Ok(Action::NewWindow {
name: Some("logs".to_string()),
})
);
assert_eq!(
parse_action("display-message hello world"),
Ok(Action::DisplayMessage {
text: "hello world".to_string(),
})
);
assert_eq!(
parse_action("set-option border heavy"),
Ok(Action::SetOption {
key: "border".to_string(),
value: "heavy".to_string(),
})
);
}
#[test]
fn unknown_action_is_structured_error() {
let err = parse_action("frobnicate").unwrap_err();
assert_eq!(err, ActionParseError::UnknownAction("frobnicate".into()));
}
#[test]
fn select_window_rejects_non_integer() {
let err = parse_action("select-window foo").unwrap_err();
assert!(matches!(err, ActionParseError::InvalidArgument { .. }));
}
#[test]
fn parses_named_buffer_actions() {
assert_eq!(
parse_action("set-buffer foo hello world"),
Ok(Action::SetBuffer {
name: "foo".to_string(),
value: "hello world".to_string(),
})
);
assert_eq!(
parse_action("paste-buffer foo"),
Ok(Action::PasteBuffer {
name: Some("foo".to_string()),
})
);
assert_eq!(
parse_action("paste-buffer"),
Ok(Action::PasteBuffer { name: None })
);
assert_eq!(parse_action("list-buffers"), Ok(Action::ListBuffers));
}
#[test]
fn buffer_actions_kind_strings_are_stable() {
assert_eq!(
Action::SetBuffer {
name: "n".into(),
value: "v".into()
}
.kind(),
"set-buffer",
);
assert_eq!(Action::PasteBuffer { name: None }.kind(), "paste-buffer",);
assert_eq!(
Action::PasteBuffer {
name: Some("foo".into())
}
.kind(),
"paste-buffer",
);
assert_eq!(Action::ListBuffers.kind(), "list-buffers");
}
#[test]
fn set_buffer_requires_name_and_value() {
let err = parse_action("set-buffer").unwrap_err();
assert!(
matches!(
err,
ActionParseError::MissingArgument {
action: "set-buffer",
arg: "NAME"
}
),
"expected missing NAME, got {err:?}",
);
let err = parse_action("set-buffer onlyname").unwrap_err();
assert!(
matches!(
err,
ActionParseError::MissingArgument {
action: "set-buffer",
arg: "VALUE"
}
),
"expected missing VALUE, got {err:?}",
);
}
#[test]
fn vocabulary_includes_every_action_kind() {
let probes = [
"split-window-h",
"split-window-v",
"kill-pane",
"new-window",
"rename-window",
"kill-window",
"select-window 0",
"next-window",
"previous-window",
"select-pane right",
"resize-pane up",
"swap-pane",
"equalize",
"select-layout ide",
"detach-session",
"kill-session",
"copy-mode",
"cancel",
"begin-selection",
"copy-selection-and-cancel",
"reload-config",
"command-prompt",
"toggle-settings",
"toggle-broadcast",
"display-message hi",
"set-option a b",
"set-buffer name value",
"paste-buffer",
"list-buffers",
];
for p in probes {
let action = parse_action(p).unwrap_or_else(|e| panic!("{p:?}: {e}"));
assert!(
Action::vocabulary().contains(&action.kind()),
"kind {} missing from vocabulary",
action.kind()
);
}
}
#[test]
fn parses_single_chars() {
assert_eq!(
parse_chord("d"),
Ok(chord(KeyCode::Char('d'), KeyModifiers::NONE))
);
assert_eq!(
parse_chord("?"),
Ok(chord(KeyCode::Char('?'), KeyModifiers::NONE))
);
assert_eq!(
parse_chord("A"),
Ok(chord(KeyCode::Char('a'), KeyModifiers::NONE))
);
}
#[test]
fn parses_modified_chords() {
assert_eq!(
parse_chord("C-Right"),
Ok(chord(KeyCode::Right, KeyModifiers::CONTROL))
);
assert_eq!(
parse_chord("M-x"),
Ok(chord(KeyCode::Char('x'), KeyModifiers::ALT))
);
assert_eq!(
parse_chord("S-Tab"),
Ok(chord(KeyCode::Tab, KeyModifiers::SHIFT))
);
assert_eq!(
parse_chord("C-M-Right"),
Ok(chord(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::ALT
))
);
assert_eq!(
parse_chord("M-C-Right"),
Ok(chord(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::ALT
))
);
}
#[test]
fn parses_named_keys() {
assert_eq!(
parse_chord("F2"),
Ok(chord(KeyCode::F(2), KeyModifiers::NONE))
);
assert_eq!(
parse_chord("Enter"),
Ok(chord(KeyCode::Enter, KeyModifiers::NONE))
);
assert_eq!(
parse_chord("Esc"),
Ok(chord(KeyCode::Esc, KeyModifiers::NONE))
);
assert_eq!(
parse_chord("Space"),
Ok(chord(KeyCode::Char(' '), KeyModifiers::NONE))
);
assert_eq!(
parse_chord("PageUp"),
Ok(chord(KeyCode::PageUp, KeyModifiers::NONE))
);
}
#[test]
fn rejects_dangling_modifier() {
assert_eq!(
parse_chord("C-").unwrap_err(),
KeyParseError::DanglingModifier
);
}
#[test]
fn rejects_unknown_named_key() {
let err = parse_chord("Banana").unwrap_err();
assert!(matches!(err, KeyParseError::UnknownNamedKey(_)));
}
#[test]
fn from_event_strips_irrelevant_modifiers_and_normalizes_case() {
let ev = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE);
let c = KeyChord::from_event(ev);
assert_eq!(c.code, KeyCode::Char('a'));
assert!(c.mods.is_empty());
let ev = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
let c = KeyChord::from_event(ev);
assert_eq!(c.code, KeyCode::Char('A'));
assert_eq!(c.mods, KeyModifiers::SHIFT);
}
#[test]
fn lookup_returns_bound_action() {
let mut km = Keymap::new();
km.bind(
KeymapTable::Prefix,
parse_chord("d").unwrap(),
Action::DetachSession,
);
let hit = km.lookup(KeymapTable::Prefix, &parse_chord("d").unwrap());
assert_eq!(hit, Some(&Action::DetachSession));
assert!(km
.lookup(KeymapTable::Normal, &parse_chord("d").unwrap())
.is_none());
}
#[test]
fn clear_drops_bindings_for_table_only() {
let mut km = Keymap::new();
km.bind(
KeymapTable::Prefix,
parse_chord("d").unwrap(),
Action::DetachSession,
);
km.bind(
KeymapTable::Normal,
parse_chord("F2").unwrap(),
Action::Equalize,
);
km.clear(KeymapTable::Prefix);
assert_eq!(km.len(KeymapTable::Prefix), 0);
assert_eq!(km.len(KeymapTable::Normal), 1);
}
#[test]
fn apply_table_binds_and_clears() {
let mut km = Keymap::new();
km.bind(
KeymapTable::Prefix,
parse_chord("d").unwrap(),
Action::DetachSession,
);
let raw: toml::Table = toml::from_str(
r#"
clear = true
"d" = "kill-window"
"%" = "split-window-h"
"#,
)
.unwrap();
apply_table(&mut km, KeymapTable::Prefix, &raw).unwrap();
let hit = km.lookup(KeymapTable::Prefix, &parse_chord("d").unwrap());
assert_eq!(hit, Some(&Action::KillWindow));
}
#[test]
fn apply_table_rejects_unknown_action_with_structured_error() {
let mut km = Keymap::new();
let raw: toml::Table = toml::from_str(r#""d" = "frobnicate""#).unwrap();
let err = apply_table(&mut km, KeymapTable::Prefix, &raw).unwrap_err();
assert_eq!(err.table, "prefix");
assert_eq!(err.key, "d");
assert!(err.message.contains("unknown action"));
}
#[test]
fn apply_table_rejects_invalid_key_spec() {
let mut km = Keymap::new();
let raw: toml::Table = toml::from_str(r#""C-" = "kill-pane""#).unwrap();
let err = apply_table(&mut km, KeymapTable::Prefix, &raw).unwrap_err();
assert!(err.message.contains("invalid key"), "{err}");
}
#[test]
fn apply_keymap_section_rejects_unknown_table() {
let mut km = Keymap::new();
let raw: toml::Table = toml::from_str(
r#"
[mystery]
"d" = "kill-pane"
"#,
)
.unwrap();
let err = apply_keymap_section(&mut km, &raw).unwrap_err();
assert_eq!(err.table, "mystery");
assert!(err.message.contains("unknown keymap table"));
}
#[test]
fn defaults_load_without_panic_and_have_bindings() {
let km = load_defaults();
assert!(!km.is_empty(), "defaults must contain at least one binding");
assert!(
km.len(KeymapTable::Prefix) > 0,
"defaults must have prefix bindings"
);
}
#[test]
fn user_clear_drops_defaults_full_path() {
let mut km = load_defaults();
let before = km.len(KeymapTable::Prefix);
assert!(before > 0);
let raw: toml::Table = toml::from_str(
r#"
[prefix]
clear = true
"x" = "kill-pane"
"#,
)
.unwrap();
apply_keymap_section(&mut km, &raw).unwrap();
assert_eq!(km.len(KeymapTable::Prefix), 1);
}
#[test]
fn user_can_remap_prefix_d_to_kill_window() {
let mut km = load_defaults();
let raw: toml::Table = toml::from_str(
r#"
[prefix]
"d" = "kill-window"
"#,
)
.unwrap();
apply_keymap_section(&mut km, &raw).unwrap();
let hit = km.lookup(KeymapTable::Prefix, &parse_chord("d").unwrap());
assert_eq!(hit, Some(&Action::KillWindow));
}
}