feat: add configurable Ctrl+j/k list navigation
This commit is contained in:
+400
-32
@@ -445,7 +445,7 @@ fn control_key(ch: char) -> Input {
|
||||
match ch.to_ascii_lowercase() {
|
||||
'h' => Input::Backspace,
|
||||
'i' => Input::Char('\t'),
|
||||
'j' | 'm' => Input::Enter,
|
||||
'm' => Input::Enter,
|
||||
ch => Input::Ctrl(ch),
|
||||
}
|
||||
}
|
||||
@@ -590,6 +590,14 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
Submit
|
||||
},
|
||||
),
|
||||
(
|
||||
Input::Ctrl('j'),
|
||||
if mode == Mode::Insert {
|
||||
Newline
|
||||
} else {
|
||||
Submit
|
||||
},
|
||||
),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
@@ -698,6 +706,8 @@ pub struct Editor {
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||||
selection_bindings: HashMap<Input, Binding>,
|
||||
selection_navigation_hint: String,
|
||||
leader: Input,
|
||||
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
|
||||
leader_suffix: Vec<Input>,
|
||||
@@ -723,6 +733,17 @@ impl Editor {
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
bindings: default_bindings(),
|
||||
selection_bindings: [
|
||||
(Input::Up, Action::Up),
|
||||
(Input::Ctrl('k'), Action::Up),
|
||||
(Input::Down, Action::Down),
|
||||
(Input::Ctrl('j'), Action::Down),
|
||||
(Input::Ctrl('n'), Action::Down),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, action)| (key, Binding::Action(action)))
|
||||
.collect(),
|
||||
selection_navigation_hint: String::new(),
|
||||
leader: Input::Char(' '),
|
||||
leader_bindings: HashMap::new(),
|
||||
leader_suffix: Vec::new(),
|
||||
@@ -733,6 +754,7 @@ impl Editor {
|
||||
file_explorer_side: DockSide::Right,
|
||||
count: None,
|
||||
};
|
||||
editor.refresh_selection_navigation_hint();
|
||||
editor
|
||||
.install(
|
||||
Feature {
|
||||
@@ -1005,9 +1027,21 @@ impl Editor {
|
||||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||||
}
|
||||
|
||||
/// Overrides one mode's single key or symbolic `leader` sequence. `None`
|
||||
/// disables it. Invalid overrides leave existing bindings intact.
|
||||
/// Overrides an editing mode's key/leader route, or a single `selection` key
|
||||
/// for list navigation. `None` disables it; invalid overrides are atomic.
|
||||
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
||||
if mode == "selection" {
|
||||
let input = parse_key(key)?;
|
||||
let binding = match target.as_deref() {
|
||||
None => Binding::Disabled,
|
||||
Some("core.up") => Binding::Action(Action::Up),
|
||||
Some("core.down") => Binding::Action(Action::Down),
|
||||
Some(target) => return Err(format!("Unknown selection action: {target}")),
|
||||
};
|
||||
self.selection_bindings.insert(input, binding);
|
||||
self.refresh_selection_navigation_hint();
|
||||
return Ok(());
|
||||
}
|
||||
let mode = match mode {
|
||||
"normal" => Mode::Normal,
|
||||
"insert" => Mode::Insert,
|
||||
@@ -1077,6 +1111,58 @@ impl Editor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn selection_navigation_hint(&self) -> &str {
|
||||
&self.selection_navigation_hint
|
||||
}
|
||||
|
||||
fn refresh_selection_navigation_hint(&mut self) {
|
||||
let keys = |action| {
|
||||
let mut keys: Vec<_> = self
|
||||
.selection_bindings
|
||||
.iter()
|
||||
.filter(
|
||||
|(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action),
|
||||
)
|
||||
.map(|(input, _)| display_key(input))
|
||||
.collect();
|
||||
keys.sort();
|
||||
if keys.is_empty() {
|
||||
"unbound".into()
|
||||
} else {
|
||||
keys.join("/")
|
||||
}
|
||||
};
|
||||
self.selection_navigation_hint = format!(
|
||||
"{} next · {} previous",
|
||||
keys(Action::Down),
|
||||
keys(Action::Up)
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_selection_binding(&mut self, input: &Input) -> bool {
|
||||
let next = match self.selection_bindings.get(input) {
|
||||
Some(Binding::Action(Action::Down)) => true,
|
||||
Some(Binding::Action(Action::Up)) => false,
|
||||
Some(Binding::Disabled) => return true,
|
||||
None => return false,
|
||||
_ => unreachable!("selection bindings only accept navigation or disabled"),
|
||||
};
|
||||
if let Some(picker) = self.file_picker.as_mut().filter(|picker| picker.focused) {
|
||||
picker.navigate(next);
|
||||
} else if let Some(palette) = self.palette.as_mut()
|
||||
&& !palette.entries.is_empty()
|
||||
{
|
||||
palette.selected = if next {
|
||||
(palette.selected + 1) % palette.entries.len()
|
||||
} else if palette.selected == 0 {
|
||||
palette.entries.len() - 1
|
||||
} else {
|
||||
palette.selected - 1
|
||||
};
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn palette_bindings(&self, action: Option<Action>, command: &str) -> Vec<String> {
|
||||
// ponytail: scan mode bindings per result; index by target if catalogs grow.
|
||||
let mut bindings: Vec<_> = self
|
||||
@@ -1180,16 +1266,6 @@ impl Editor {
|
||||
let palette = self.palette.as_mut().expect("open palette receives input");
|
||||
match input {
|
||||
Input::Escape | Input::Ctrl('p') => self.palette = None,
|
||||
Input::Up | Input::Ctrl('k') if !palette.entries.is_empty() => {
|
||||
palette.selected = if palette.selected == 0 {
|
||||
palette.entries.len() - 1
|
||||
} else {
|
||||
palette.selected - 1
|
||||
};
|
||||
}
|
||||
Input::Down | Input::Ctrl('n') if !palette.entries.is_empty() => {
|
||||
palette.selected = (palette.selected + 1) % palette.entries.len();
|
||||
}
|
||||
Input::Enter => {
|
||||
let entry = palette
|
||||
.entries
|
||||
@@ -1260,8 +1336,6 @@ impl Editor {
|
||||
Input::Char('\t') if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => picker.navigate(false),
|
||||
Input::Down | Input::Ctrl('n') => picker.navigate(true),
|
||||
Input::Ctrl('r') => picker.refresh(),
|
||||
Input::Backspace => picker.backspace(),
|
||||
Input::Char(ch) if !ch.is_control() => {
|
||||
@@ -1292,23 +1366,23 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||||
if self
|
||||
// Normalize genuine terminal aliases before list and editing dispatch.
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
let picker_focused = self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
.is_some_and(|picker| picker.focused);
|
||||
if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) {
|
||||
return None;
|
||||
}
|
||||
if picker_focused {
|
||||
self.handle_file_picker(input);
|
||||
return None;
|
||||
}
|
||||
if self.palette.is_some() {
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
return self.handle_palette(input);
|
||||
}
|
||||
// Paste is literal input, never a configurable key or command.
|
||||
@@ -1320,11 +1394,6 @@ impl Editor {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// Normalize terminal aliases for every frontend.
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.leader_pending().is_some() {
|
||||
if input == Input::Escape {
|
||||
self.cancel_leader();
|
||||
@@ -3122,6 +3191,288 @@ mod tests {
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_navigation_wraps_without_touching_any_underlying_mode() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a.txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "file text").unwrap();
|
||||
}
|
||||
for surface in [
|
||||
None,
|
||||
Some(FilePresentation::Floating),
|
||||
Some(FilePresentation::Docked),
|
||||
] {
|
||||
for mode in [
|
||||
Mode::Normal,
|
||||
Mode::Insert,
|
||||
Mode::Command,
|
||||
Mode::Search,
|
||||
Mode::Visual,
|
||||
] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("alpha\nbeta".into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
|
||||
editor.active.mode = mode;
|
||||
editor.command_line = "original prompt".into();
|
||||
if let Some(presentation) = surface {
|
||||
editor.open_file_picker(presentation);
|
||||
type_text(&mut editor, "txt");
|
||||
} else {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "core");
|
||||
}
|
||||
let list = |editor: &Editor| {
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
(
|
||||
picker.query.clone(),
|
||||
picker.selected,
|
||||
picker.entries().len(),
|
||||
)
|
||||
} else {
|
||||
let palette = editor.palette().unwrap();
|
||||
(
|
||||
palette.query.clone(),
|
||||
palette.selected,
|
||||
palette.entries.len(),
|
||||
)
|
||||
}
|
||||
};
|
||||
let (query, _, len) = list(&editor);
|
||||
assert!(len > 1);
|
||||
let snapshot =
|
||||
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
|
||||
let text = editor.context().text;
|
||||
let targets = editor.active.targets.clone();
|
||||
let primary = editor.active.primary;
|
||||
let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect();
|
||||
let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect();
|
||||
let insert = editor.active.insert_start.as_ref().map(snapshot);
|
||||
editor.count = Some(7);
|
||||
for (input, selected) in [
|
||||
(Input::Ctrl('K'), len - 1),
|
||||
(Input::Ctrl('J'), 0),
|
||||
(Input::Ctrl('j'), 1),
|
||||
(Input::Ctrl('k'), 0),
|
||||
(Input::Up, len - 1),
|
||||
(Input::Down, 0),
|
||||
(Input::Ctrl('n'), 1),
|
||||
] {
|
||||
assert_eq!(editor.handle(input), None);
|
||||
assert_eq!(list(&editor), (query.clone(), selected, len));
|
||||
}
|
||||
assert_eq!(editor.mode(), mode);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.active.targets, targets);
|
||||
assert_eq!(editor.active.primary, primary);
|
||||
assert_eq!(editor.command_line, "original prompt");
|
||||
assert_eq!(editor.count(), Some(7));
|
||||
assert_eq!(
|
||||
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||||
undo
|
||||
);
|
||||
assert_eq!(
|
||||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||||
redo
|
||||
);
|
||||
assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert);
|
||||
type_text(&mut editor, "missing");
|
||||
assert_eq!(list(&editor).2, 0);
|
||||
for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] {
|
||||
assert_eq!(editor.handle(input), None);
|
||||
assert_eq!(list(&editor).1, 0);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.mode(), mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a.txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "file text").unwrap();
|
||||
}
|
||||
for surface in [
|
||||
None,
|
||||
Some(FilePresentation::Floating),
|
||||
Some(FilePresentation::Docked),
|
||||
] {
|
||||
let mut editor = project_editor(&directory);
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous"
|
||||
);
|
||||
type_text(&mut editor, "3 b");
|
||||
let pending = editor.leader_pending().unwrap().to_owned();
|
||||
editor
|
||||
.bind("selection", "x", Some("core.down".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("selection", "z", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.bind("selection", "ctrl+j", None).unwrap();
|
||||
editor.bind("selection", "!", None).unwrap();
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
assert_eq!(editor.leader_pending(), Some(pending.as_str()));
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"Ctrl+n/Down/x next · Ctrl+k/Up/z previous"
|
||||
);
|
||||
let hint = editor.selection_navigation_hint().to_owned();
|
||||
for (mode, key, target) in [
|
||||
("selection", "x", Some("core.delete")),
|
||||
("selection", "x", Some("core.palette")),
|
||||
("selection", "x", Some("w")),
|
||||
("selection", "leader x", Some("core.down")),
|
||||
("selection", "LEADER x", None),
|
||||
("selection", "x z", Some("core.up")),
|
||||
("selection", "alt+x", Some("core.up")),
|
||||
("invalid", "x", Some("core.up")),
|
||||
] {
|
||||
assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err());
|
||||
assert_eq!(editor.selection_navigation_hint(), hint);
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
assert_eq!(editor.leader_pending(), Some(pending.as_str()));
|
||||
}
|
||||
editor.cancel_leader();
|
||||
// Editing bindings cannot disable or remap list bindings.
|
||||
editor
|
||||
.bind("normal", "x", Some("core.insert".into()))
|
||||
.unwrap();
|
||||
editor.bind("normal", "ctrl+k", None).unwrap();
|
||||
if let Some(presentation) = surface {
|
||||
editor.open_file_picker(presentation);
|
||||
} else {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
}
|
||||
let selected = |editor: &Editor| {
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
picker.selected
|
||||
} else {
|
||||
editor.palette().unwrap().selected
|
||||
}
|
||||
};
|
||||
for (input, expected) in [
|
||||
(Input::Char('x'), 1),
|
||||
(Input::Ctrl('j'), 1),
|
||||
(Input::Char('!'), 1),
|
||||
(Input::Char('z'), 0),
|
||||
(Input::Char('x'), 1),
|
||||
(Input::Ctrl('k'), 0),
|
||||
] {
|
||||
editor.handle(input);
|
||||
assert_eq!(selected(&editor), expected);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
// Updating the map does not rebuild/reset the selected list.
|
||||
editor.handle(Input::Char('x'));
|
||||
editor
|
||||
.bind("selection", "ctrl+p", Some("core.down".into()))
|
||||
.unwrap();
|
||||
assert_eq!(selected(&editor), 1);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert_eq!(selected(&editor), 2);
|
||||
editor.bind("selection", "backspace", None).unwrap();
|
||||
type_text(&mut editor, "a");
|
||||
editor.handle(Input::Backspace);
|
||||
let query = if let Some(picker) = editor.file_picker() {
|
||||
&picker.query
|
||||
} else {
|
||||
&editor.palette().unwrap().query
|
||||
};
|
||||
assert_eq!(query, "a");
|
||||
for key in ["down", "ctrl+n", "x", "ctrl+p"] {
|
||||
editor.bind("selection", key, None).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"unbound next · Ctrl+k/Up/z previous"
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('x'));
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() {
|
||||
assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j'));
|
||||
for (key, input) in [
|
||||
("ctrl+m", Input::Enter),
|
||||
("ctrl+h", Input::Backspace),
|
||||
("ctrl+i", Input::Char('\t')),
|
||||
] {
|
||||
assert_eq!(parse_key(key).unwrap(), input);
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.bind("normal", "ctrl+j", Some("core.insert".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
editor.bind("insert", "enter", None).unwrap();
|
||||
type_text(&mut editor, "one");
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
type_text(&mut editor, "two");
|
||||
assert_eq!(editor.context().text, "one\ntwo");
|
||||
editor.handle(Input::Escape);
|
||||
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||
type_text(&mut editor, ":greet arguments");
|
||||
let invocation = editor.handle(Input::Ctrl('j')).unwrap();
|
||||
assert_eq!(invocation.command, "greet");
|
||||
assert_eq!(invocation.args, "arguments");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
type_text(&mut editor, "/two");
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.active.search_query, "two");
|
||||
assert_eq!(editor.cursor().row, 1);
|
||||
editor
|
||||
.bind("visual", "ctrl+j", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_j_navigates_lists_while_enter_accepts() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("a.txt"), "first").unwrap();
|
||||
fs::write(directory.path("b.txt"), "second").unwrap();
|
||||
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.open_file_picker(presentation);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 1);
|
||||
assert_eq!(editor.context().text, "");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.context().text, "second");
|
||||
assert_eq!(editor.path(), Some(directory.path("b.txt").as_path()));
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.register(plugin("fixture", &["greet_one", "greet_two"]))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "greet");
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.palette().unwrap().selected, 1);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.command_line, "");
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line, "greet_two ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_actions_default_leaders_palette_and_side_configuration() {
|
||||
let directory = Directory::new();
|
||||
@@ -3239,6 +3590,8 @@ mod tests {
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 0);
|
||||
@@ -4595,6 +4948,8 @@ mod tests {
|
||||
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.palette().is_some());
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
type_text(&mut editor, "query Δe\u{301}👩🚀 ");
|
||||
editor.handle(Input::Backspace);
|
||||
editor.handle(Input::Backspace);
|
||||
@@ -4676,6 +5031,7 @@ mod tests {
|
||||
Input::Enter,
|
||||
Input::Up,
|
||||
Input::Down,
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Ctrl('n'),
|
||||
] {
|
||||
@@ -4720,6 +5076,9 @@ mod tests {
|
||||
assert!(!entry.enabled);
|
||||
assert_eq!(entry.modes, vec![Mode::Normal]);
|
||||
assert!(entry.bindings.is_empty());
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
assert_eq!(editor.palette().unwrap().query, "core.undo");
|
||||
assert_eq!(editor.handle(Input::Enter), None);
|
||||
assert!(editor.palette().is_some());
|
||||
assert_eq!(editor.mode(), mode);
|
||||
@@ -4755,7 +5114,16 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
palette_entry(&editor, "core.insert").bindings,
|
||||
vec!["Backspace", "Ctrl+r", "Enter", "I", "Space", "Tab", "界"]
|
||||
vec![
|
||||
"Backspace",
|
||||
"Ctrl+j",
|
||||
"Ctrl+r",
|
||||
"Enter",
|
||||
"I",
|
||||
"Space",
|
||||
"Tab",
|
||||
"界"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
palette_entry(&editor, "core.insert_first_nonblank")
|
||||
|
||||
Reference in New Issue
Block a user