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}'));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
|
||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
|
||||
Default keys (configurable with config.bind):
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
u undo, U redo, : command
|
||||
|
||||
+7
-16
@@ -16,7 +16,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Config {
|
||||
pub plugins: Vec<PathBuf>,
|
||||
pub bindings: Vec<(char, String)>,
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
/// Relative plugin paths are resolved against the configuration file's directory.
|
||||
@@ -37,16 +37,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
}
|
||||
config.plugins.push(directory.join(plugin));
|
||||
}
|
||||
for binding in bindings {
|
||||
let mut chars = binding.key.chars();
|
||||
let key = chars
|
||||
.next()
|
||||
.ok_or("Ruby binding key must be one character")?;
|
||||
if chars.next().is_some() || binding.command.is_empty() {
|
||||
return Err("Ruby binding requires one character and a nonempty command".into());
|
||||
}
|
||||
config.bindings.push((key, binding.command));
|
||||
}
|
||||
config.bindings = bindings;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -224,11 +215,12 @@ impl Response {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Binding {
|
||||
key: String,
|
||||
command: String,
|
||||
pub struct Binding {
|
||||
pub mode: String,
|
||||
pub key: String,
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -546,7 +538,6 @@ end
|
||||
);
|
||||
let loaded = load_config(&config).unwrap();
|
||||
assert_eq!(loaded.plugins, [fixture.0.join("plugins/greeting.rb")]);
|
||||
assert_eq!(loaded.bindings, [('g', "greet".into())]);
|
||||
let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }");
|
||||
assert!(
|
||||
load_config(&invalid)
|
||||
|
||||
+6
-5
@@ -31,12 +31,13 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
def bind(key, command)
|
||||
def bind(key, command, mode: "normal")
|
||||
key = Vamp.string!(key, "binding key")
|
||||
command = Vamp.string!(command, "binding command")
|
||||
raise ArgumentError, "binding key must be one character" unless key.length == 1
|
||||
raise ArgumentError, "binding command must not be empty" if command.empty?
|
||||
@bindings << { "key" => key, "command" => command }
|
||||
mode = Vamp.string!(mode, "binding mode")
|
||||
command = Vamp.string!(command, "binding command") unless command.nil?
|
||||
raise ArgumentError, "binding key must not be empty" if key.empty?
|
||||
raise ArgumentError, "binding command must not be empty" if command == ""
|
||||
@bindings << { "mode" => mode, "key" => key, "command" => command }
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
+100
-4
@@ -48,13 +48,16 @@ impl EditorSession {
|
||||
Err(error) => notices.push(format!("{}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
for (key, command) in config.bindings {
|
||||
if let Err(error) = editor.bind(key, command) {
|
||||
notices.push(format!("binding {key:?}: {error}"));
|
||||
for binding in config.bindings {
|
||||
if let Err(error) = editor.bind(&binding.mode, &binding.key, binding.command) {
|
||||
notices.push(format!(
|
||||
"binding {} {:?}: {error}",
|
||||
binding.mode, binding.key
|
||||
));
|
||||
}
|
||||
}
|
||||
if notices.is_empty() {
|
||||
editor.set_message("i: insert • Esc: normal • :w save • :q quit • :plugins");
|
||||
editor.set_message("Ready • --no-config uses default keybindings");
|
||||
} else {
|
||||
editor.set_message(notices.join(" | "));
|
||||
}
|
||||
@@ -318,6 +321,99 @@ end
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_bindings_preserve_modal_editing_history_and_literal_paste() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.healthy();
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.plugin "healthy.rb"
|
||||
config.bind "n", "core.insert_first_nonblank"
|
||||
config.bind "i", nil
|
||||
config.bind "<C-r>", "core.redo"
|
||||
config.bind "<C-c>", "core.normal", mode: "insert"
|
||||
config.bind "<Esc>", nil, mode: "insert"
|
||||
config.bind "!", "core.newline", mode: "insert"
|
||||
config.bind "X", nil, mode: "insert"
|
||||
config.bind ":", nil
|
||||
config.bind "<Space>", "core.command"
|
||||
config.bind "<Enter>", nil, mode: "command"
|
||||
config.bind "<C-x>", "core.submit", mode: "command"
|
||||
config.bind "<C-c>", "core.normal", mode: "command"
|
||||
config.bind "<C-h>", "core.backspace", mode: "command"
|
||||
config.bind "g", "core.undo"
|
||||
config.bind "g", "healthy"
|
||||
config.bind "n", "core.missing"
|
||||
config.bind "n", "core.undo", mode: "missing"
|
||||
config.bind "<Unknown>", "core.undo"
|
||||
config.bind "<C-f>", "healthy", mode: "insert"
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let message = session.editor().message();
|
||||
for error in [
|
||||
"Unknown action for NORMAL: core.missing",
|
||||
"Unknown binding mode: missing",
|
||||
"Invalid binding key",
|
||||
"Ex/plugin commands can only be bound in Normal mode",
|
||||
] {
|
||||
assert!(message.contains(error), "{message}");
|
||||
}
|
||||
session.handle(Input::Char('i'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Char('n'));
|
||||
for ch in "界Xab!".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
assert_eq!(session.editor().lines(), &["界ab", ""]);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
|
||||
session.handle(Input::Paste("X!".into()));
|
||||
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
|
||||
session.handle(Input::Ctrl('C'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["界ab", ""]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &[String::new()]);
|
||||
session.handle(Input::Ctrl('r'));
|
||||
session.handle(Input::Ctrl('r'));
|
||||
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
|
||||
session.handle(Input::Char(':'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Char(' '));
|
||||
for ch in "w界".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Backspace);
|
||||
assert_eq!(session.editor().command_line(), "w");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"界ab\nX!"
|
||||
);
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Ctrl('c'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
assert!(session.editor().command_line().is_empty());
|
||||
assert!(!session.editor().should_quit());
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["healthy "]);
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert!(!session.editor().should_quit());
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert!(session.editor().should_quit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_config_falls_back_to_core_without_failing_the_session() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
Reference in New Issue
Block a user