feat: make keybindings configurable
This commit is contained in:
+318
-108
@@ -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}'));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user