feat: make keybindings configurable

This commit is contained in:
Valdemar Lorenzen
2026-10-05 15:57:33 +02:00
parent 368442ba29
commit 3fd89fcbed
7 changed files with 466 additions and 137 deletions
+318 -108
View File
@@ -13,7 +13,7 @@ use unicode_segmentation::UnicodeSegmentation;
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Input {
Char(char),
Ctrl(char),
@@ -30,7 +30,7 @@ pub enum Input {
End,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Mode {
Normal,
Insert,
@@ -92,6 +92,181 @@ struct Snapshot {
cursor: Cursor,
}
#[derive(Clone, Copy)]
enum Action {
Insert,
InsertFirstNonblank,
Append,
AppendLineEnd,
OpenBelow,
OpenAbove,
Left,
Right,
Down,
Up,
JoinLines,
LineStart,
LineEnd,
Delete,
Undo,
Redo,
Command,
Normal,
Newline,
Backspace,
Submit,
}
impl Action {
fn named(mode: Mode, name: &str) -> Option<Self> {
use Action::*;
Some(match (mode, name) {
(Mode::Normal, "core.insert") => Insert,
(Mode::Normal, "core.insert_first_nonblank") => InsertFirstNonblank,
(Mode::Normal, "core.append") => Append,
(Mode::Normal, "core.append_line_end") => AppendLineEnd,
(Mode::Normal, "core.open_below") => OpenBelow,
(Mode::Normal, "core.open_above") => OpenAbove,
(Mode::Normal, "core.join_lines") => JoinLines,
(Mode::Normal, "core.undo") => Undo,
(Mode::Normal, "core.redo") => Redo,
(Mode::Normal, "core.command") => Command,
(Mode::Normal | Mode::Insert, "core.left") => Left,
(Mode::Normal | Mode::Insert, "core.right") => Right,
(Mode::Normal | Mode::Insert, "core.down") => Down,
(Mode::Normal | Mode::Insert, "core.up") => Up,
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert, "core.delete") => Delete,
(Mode::Insert | Mode::Command, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline,
(Mode::Insert | Mode::Command, "core.backspace") => Backspace,
(Mode::Command, "core.submit") => Submit,
_ => return None,
})
}
}
enum Binding {
Action(Action),
Command(String),
Disabled,
}
fn parse_key(key: &str) -> Result<Input, String> {
let mut chars = key.chars();
if let Some(ch) = chars.next()
&& chars.next().is_none()
&& !ch.is_control()
{
return Ok(Input::Char(ch));
}
Ok(match key {
"<Esc>" => Input::Escape,
"<Enter>" => Input::Enter,
"<Backspace>" => Input::Backspace,
"<Delete>" => Input::Delete,
"<Left>" => Input::Left,
"<Right>" => Input::Right,
"<Up>" => Input::Up,
"<Down>" => Input::Down,
"<Home>" => Input::Home,
"<End>" => Input::End,
"<Tab>" => Input::Char('\t'),
"<Space>" => Input::Char(' '),
_ => {
if let Some(control) = key
.strip_prefix("<C-")
.and_then(|key| key.strip_suffix('>'))
{
let mut chars = control.chars();
if let Some(ch) = chars.next()
&& ch.is_ascii_alphabetic()
&& chars.next().is_none()
{
return Ok(control_key(ch));
}
}
return Err(format!("Invalid binding key: {key:?}"));
}
})
}
fn control_key(ch: char) -> Input {
// Terminals encode these controls as named keys.
match ch.to_ascii_lowercase() {
'h' => Input::Backspace,
'i' => Input::Char('\t'),
'j' | 'm' => Input::Enter,
ch => Input::Ctrl(ch),
}
}
fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
use Action::*;
let mut bindings: HashMap<Mode, HashMap<Input, Binding>> = HashMap::new();
for (key, action) in [
('i', Insert),
('I', InsertFirstNonblank),
('a', Append),
('A', AppendLineEnd),
('o', OpenBelow),
('O', OpenAbove),
('h', Left),
('j', Down),
('k', Up),
('l', Right),
('J', JoinLines),
('0', LineStart),
('$', LineEnd),
('x', Delete),
('u', Undo),
('U', Redo),
(':', Command),
] {
bindings
.entry(Mode::Normal)
.or_default()
.insert(Input::Char(key), Binding::Action(action));
}
for mode in [Mode::Normal, Mode::Insert] {
for (key, action) in [
(Input::Left, Left),
(Input::Right, Right),
(Input::Down, Down),
(Input::Up, Up),
(Input::Home, LineStart),
(Input::End, LineEnd),
(Input::Delete, Delete),
] {
bindings
.entry(mode)
.or_default()
.insert(key, Binding::Action(action));
}
}
for mode in [Mode::Insert, Mode::Command] {
for (key, action) in [
(Input::Escape, Normal),
(Input::Backspace, Backspace),
(
Input::Enter,
if mode == Mode::Insert {
Newline
} else {
Submit
},
),
] {
bindings
.entry(mode)
.or_default()
.insert(key, Binding::Action(action));
}
}
bindings
}
pub struct Editor {
lines: Vec<String>,
saved_lines: Vec<String>,
@@ -103,7 +278,7 @@ pub struct Editor {
quit: bool,
capabilities: Vec<String>,
features: HashMap<String, Feature>,
bindings: HashMap<char, String>,
bindings: HashMap<Mode, HashMap<Input, Binding>>,
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
@@ -133,7 +308,7 @@ impl Editor {
quit: false,
capabilities,
features: HashMap::new(),
bindings: HashMap::new(),
bindings: default_bindings(),
undo: Vec::new(),
redo: Vec::new(),
insert_start: None,
@@ -252,120 +427,157 @@ impl Editor {
return;
}
if let Some(feature) = self.features.remove(name) {
self.bindings
.retain(|_, command| !feature.commands.contains(command));
for bindings in self.bindings.values_mut() {
bindings.retain(|_, binding| {
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
});
}
}
self.set_message(format!("Plugin {name} disabled: {reason}"));
}
pub fn bind(&mut self, key: char, command: String) -> Result<(), String> {
if key.is_control() || "iIaAoOhjJklx0$uU:".contains(key) {
return Err(format!("Key {key:?} is reserved for editing"));
}
if self.owner(&command).is_none() {
return Err(format!("Unknown command: {command}"));
}
self.bindings.insert(key, command);
/// Overrides one mode's binding. `None` disables the key, including literal
/// typing in Insert/Command mode. Invalid overrides leave the binding intact.
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
let mode = match mode {
"normal" => Mode::Normal,
"insert" => Mode::Insert,
"command" => Mode::Command,
_ => return Err(format!("Unknown binding mode: {mode}")),
};
let key = parse_key(key)?;
let binding = match target {
None => Binding::Disabled,
Some(target) if target.starts_with("core.") => Binding::Action(
Action::named(mode, &target)
.ok_or_else(|| format!("Unknown action for {mode}: {target}"))?,
),
Some(command) => {
if mode != Mode::Normal {
return Err("Ex/plugin commands can only be bound in Normal mode".into());
}
if self.owner(&command).is_none() {
return Err(format!("Unknown command: {command}"));
}
Binding::Command(command)
}
};
self.bindings.entry(mode).or_default().insert(key, binding);
Ok(())
}
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
match self.mode {
Mode::Normal => match input {
Input::Char('i') => self.begin_insert(),
Input::Char('I') => {
self.begin_insert();
self.cursor.column = self.lines[self.cursor.row]
.graphemes(true)
.take_while(|grapheme| matches!(*grapheme, " " | "\t"))
.count();
// Paste is literal input, never a configurable key or command.
if let Input::Paste(text) = input {
if self.mode == Mode::Insert {
self.paste(&text);
}
return None;
}
// Normalize terminal aliases for every frontend.
let input = match input {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
match self
.bindings
.get(&self.mode)
.and_then(|keys| keys.get(&input))
{
Some(Binding::Action(action)) => return self.act(*action),
Some(Binding::Command(command)) => {
let command = command.clone();
return self.execute(&command);
}
Some(Binding::Disabled) => return None,
None => {}
}
match (self.mode, input) {
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
let mut bytes = [0; 4];
self.insert_text(ch.encode_utf8(&mut bytes));
}
(Mode::Command, Input::Char(ch)) if !ch.is_control() => self.command_line.push(ch),
_ => {}
}
None
}
fn act(&mut self, action: Action) -> Option<Invocation> {
match action {
Action::Insert => self.begin_insert(),
Action::InsertFirstNonblank => {
self.begin_insert();
self.cursor.column = self.lines[self.cursor.row]
.graphemes(true)
.take_while(|grapheme| matches!(*grapheme, " " | "\t"))
.count();
}
Action::Append => {
self.begin_insert();
self.cursor.column =
(self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row]));
}
Action::AppendLineEnd => {
self.begin_insert();
self.move_end();
}
Action::OpenBelow | Action::OpenAbove => {
self.begin_insert();
if matches!(action, Action::OpenBelow) {
self.cursor.row += 1;
}
Input::Char('a') => {
self.begin_insert();
self.cursor.column =
(self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row]));
self.lines.insert(self.cursor.row, String::new());
self.cursor.column = 0;
}
Action::Left => self.move_horizontal(false),
Action::Right => self.move_horizontal(true),
Action::Down => self.move_vertical(true),
Action::Up => self.move_vertical(false),
Action::JoinLines => self.join_lines(),
Action::LineStart => self.cursor.column = 0,
Action::LineEnd => self.move_end(),
Action::Delete => {
if self.mode == Mode::Insert {
self.delete_forward();
} else if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) {
let before = self.snapshot();
self.delete_forward();
self.clamp_cursor();
self.record(before);
}
Input::Char('A') => {
self.begin_insert();
self.move_end();
}
Input::Char('o') | Input::Char('O') => {
self.begin_insert();
if input == Input::Char('o') {
self.cursor.row += 1;
}
self.lines.insert(self.cursor.row, String::new());
self.cursor.column = 0;
}
Input::Char('h') | Input::Left => self.move_horizontal(false),
Input::Char('l') | Input::Right => self.move_horizontal(true),
Input::Char('j') | Input::Down => self.move_vertical(true),
Input::Char('J') => self.join_lines(),
Input::Char('k') | Input::Up => self.move_vertical(false),
Input::Char('0') | Input::Home => self.cursor.column = 0,
Input::Char('$') | Input::End => self.move_end(),
Input::Char('x') | Input::Delete => {
if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) {
let before = self.snapshot();
self.delete_forward();
self.clamp_cursor();
self.record(before);
}
}
Input::Char('u') => self.undo(),
Input::Char('U') => self.redo(),
Input::Char(':') => {
self.mode = Mode::Command;
self.command_line.clear();
}
Input::Char(key) => {
if let Some(command) = self.bindings.get(&key).cloned() {
return self.execute(&command);
}
}
_ => {}
},
Mode::Insert => match input {
Input::Escape => {
}
Action::Undo => self.undo(),
Action::Redo => self.redo(),
Action::Command => {
self.mode = Mode::Command;
self.command_line.clear();
}
Action::Normal => {
if self.mode == Mode::Insert {
self.finish_insert();
self.mode = Mode::Normal;
self.cursor.column = self.cursor.column.saturating_sub(1);
self.clamp_cursor();
}
Input::Char(ch) if !ch.is_control() || ch == '\t' => {
let mut bytes = [0; 4];
self.insert_text(ch.encode_utf8(&mut bytes));
}
Input::Paste(text) => self.paste(&text),
Input::Enter => self.insert_text("\n"),
Input::Backspace => self.backspace(),
Input::Delete => self.delete_forward(),
Input::Left => self.move_horizontal(false),
Input::Right => self.move_horizontal(true),
Input::Down => self.move_vertical(true),
Input::Up => self.move_vertical(false),
Input::Home => self.cursor.column = 0,
Input::End => self.move_end(),
_ => {}
},
Mode::Command => match input {
Input::Escape => {
} else {
self.command_line.clear();
self.mode = Mode::Normal;
}
Input::Enter => {
let command = std::mem::take(&mut self.command_line);
self.mode = Mode::Normal;
return self.execute(&command);
}
Action::Newline => self.insert_text("\n"),
Action::Backspace => {
if self.mode == Mode::Insert {
self.backspace();
} else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back()
{
self.command_line.truncate(byte);
}
Input::Backspace => {
if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() {
self.command_line.truncate(byte);
}
}
Input::Char(ch) if !ch.is_control() => self.command_line.push(ch),
_ => {}
},
}
Action::Submit => {
let command = std::mem::take(&mut self.command_line);
self.mode = Mode::Normal;
return self.execute(&command);
}
}
None
}
@@ -1140,13 +1352,13 @@ mod tests {
let mut feature = plugin("graphics", &["draw"]);
feature.required_capabilities.push("graphics".into());
assert!(editor.register(feature).is_err());
assert!(editor.bind('g', "draw".into()).is_err());
assert!(editor.bind("normal", "g", Some("draw".into())).is_err());
assert!(
editor
.register(plugin("duplicate", &["newcmd", "w"]))
.is_err()
);
assert!(editor.bind('g', "newcmd".into()).is_err());
assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err());
assert!(
editor
.register(plugin("repeat", &["same", "same"]))
@@ -1183,13 +1395,11 @@ mod tests {
}
#[test]
fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() {
fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() {
let mut editor = editor();
editor.register(plugin("greeting", &["greet"])).unwrap();
for key in "iIaAoOhjJklx0$uU:\u{1b}\r".chars() {
assert!(editor.bind(key, "greet".into()).is_err());
}
editor.bind('g', "greet".into()).unwrap();
assert!(editor.bind("insert", "g", Some("greet".into())).is_err());
editor.bind("normal", "g", Some("greet".into())).unwrap();
assert_eq!(
editor.handle(Input::Char('g')),
Some(Invocation {
@@ -1204,7 +1414,7 @@ mod tests {
editor.handle(Input::Escape);
editor.disable_plugin("greeting", "crashed\u{1b}[31m");
assert_eq!(editor.handle(Input::Char('g')), None);
assert!(editor.bind('g', "greet".into()).is_err());
assert!(editor.bind("normal", "g", Some("greet".into())).is_err());
assert!(!editor.message().contains('\u{1b}'));
}