feat: add searchable command palette

This commit is contained in:
Valdemar Lorenzen
2026-10-08 11:06:09 +02:00
parent 6ae5ccb220
commit 619c180316
6 changed files with 1171 additions and 73 deletions
+779 -67
View File
@@ -52,6 +52,23 @@ impl fmt::Display for Mode {
}
}
pub struct Palette {
pub query: String,
/// Current filtered results, sorted by label then name.
pub entries: Vec<PaletteEntry>,
pub selected: usize,
}
pub struct PaletteEntry {
pub name: String,
pub label: String,
pub owner: String,
/// Effective bindings in the underlying editor mode.
pub bindings: Vec<String>,
pub enabled: bool,
pub modes: Vec<Mode>,
}
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
pub struct Cursor {
pub row: usize,
@@ -140,7 +157,7 @@ struct Edit<'a> {
round_up: bool,
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Eq, PartialEq)]
enum Action {
Insert,
InsertFirstNonblank,
@@ -184,58 +201,166 @@ enum Action {
SelectNext,
SkipOccurrence,
SelectAll,
Palette,
}
// Names and mode eligibility are shared by configured bindings and the palette.
const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
("core.insert", Action::Insert, &[Mode::Normal]),
(
"core.insert_first_nonblank",
Action::InsertFirstNonblank,
&[Mode::Normal, Mode::Visual],
),
("core.append", Action::Append, &[Mode::Normal]),
(
"core.append_line_end",
Action::AppendLineEnd,
&[Mode::Normal, Mode::Visual],
),
("core.open_below", Action::OpenBelow, &[Mode::Normal]),
("core.open_above", Action::OpenAbove, &[Mode::Normal]),
("core.join_lines", Action::JoinLines, &[Mode::Normal]),
("core.undo", Action::Undo, &[Mode::Normal]),
("core.redo", Action::Redo, &[Mode::Normal]),
("core.command", Action::Command, &[Mode::Normal]),
("core.search", Action::Search, &[Mode::Normal]),
(
"core.search_next",
Action::SearchNext,
&[Mode::Normal, Mode::Visual],
),
(
"core.search_previous",
Action::SearchPrevious,
&[Mode::Normal, Mode::Visual],
),
("core.select", Action::Select, &[Mode::Normal, Mode::Visual]),
("core.select_lines", Action::SelectLines, &[Mode::Visual]),
(
"core.clone_above",
Action::CloneAbove,
&[Mode::Normal, Mode::Visual],
),
(
"core.clone_below",
Action::CloneBelow,
&[Mode::Normal, Mode::Visual],
),
("core.select_next", Action::SelectNext, &[Mode::Visual]),
(
"core.skip_occurrence",
Action::SkipOccurrence,
&[Mode::Visual],
),
("core.select_all", Action::SelectAll, &[Mode::Visual]),
("core.copy", Action::Copy, &[Mode::Visual]),
("core.cut", Action::Cut, &[Mode::Visual]),
("core.change", Action::Change, &[Mode::Visual]),
("core.paste_before", Action::PasteBefore, &[Mode::Normal]),
("core.paste_after", Action::PasteAfter, &[Mode::Normal]),
(
"core.left",
Action::Left,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.right",
Action::Right,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.down",
Action::Down,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.up",
Action::Up,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.line_start",
Action::LineStart,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.line_end",
Action::LineEnd,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.word_next",
Action::WordNext,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.word_previous",
Action::WordPrevious,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.word_end",
Action::WordEnd,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.big_word_next",
Action::BigWordNext,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.big_word_previous",
Action::BigWordPrevious,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
(
"core.big_word_end",
Action::BigWordEnd,
&[Mode::Normal, Mode::Insert, Mode::Visual],
),
("core.delete", Action::Delete, &[Mode::Normal, Mode::Insert]),
(
"core.normal",
Action::Normal,
&[
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
],
),
("core.newline", Action::Newline, &[Mode::Insert]),
(
"core.backspace",
Action::Backspace,
&[Mode::Insert, Mode::Command, Mode::Search],
),
(
"core.submit",
Action::Submit,
&[Mode::Command, Mode::Search],
),
(
"core.palette",
Action::Palette,
&[
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
],
),
];
impl Action {
fn named(mode: Mode, name: &str) -> Option<Self> {
use Action::*;
Some(match (mode, name) {
(Mode::Normal, "core.insert") => Insert,
(Mode::Normal | Mode::Visual, "core.insert_first_nonblank") => InsertFirstNonblank,
(Mode::Normal, "core.append") => Append,
(Mode::Normal | Mode::Visual, "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, "core.search") => Search,
(Mode::Normal | Mode::Visual, "core.search_next") => SearchNext,
(Mode::Normal | Mode::Visual, "core.search_previous") => SearchPrevious,
(Mode::Normal | Mode::Visual, "core.select") => Select,
(Mode::Visual, "core.select_lines") => SelectLines,
(Mode::Normal | Mode::Visual, "core.clone_above") => CloneAbove,
(Mode::Normal | Mode::Visual, "core.clone_below") => CloneBelow,
(Mode::Visual, "core.select_next") => SelectNext,
(Mode::Visual, "core.skip_occurrence") => SkipOccurrence,
(Mode::Visual, "core.select_all") => SelectAll,
(Mode::Visual, "core.copy") => Copy,
(Mode::Visual, "core.cut") => Cut,
(Mode::Visual, "core.change") => Change,
(Mode::Normal, "core.paste_before") => PasteBefore,
(Mode::Normal, "core.paste_after") => PasteAfter,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.left") => Left,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.right") => Right,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.down") => Down,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.up") => Up,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_next") => WordNext,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_previous") => WordPrevious,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_end") => WordEnd,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_next") => BigWordNext,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_previous") => {
BigWordPrevious
}
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_end") => BigWordEnd,
(Mode::Normal | Mode::Insert, "core.delete") => Delete,
(_, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline,
(Mode::Insert | Mode::Command | Mode::Search, "core.backspace") => Backspace,
(Mode::Command | Mode::Search, "core.submit") => Submit,
_ => return None,
})
CORE_ACTIONS
.iter()
.find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode))
.map(|(_, action, _)| *action)
}
}
@@ -312,6 +437,26 @@ fn control_key(ch: char) -> Input {
}
}
fn display_key(input: &Input) -> String {
match input {
Input::Char('\t') => "Tab".into(),
Input::Char(' ') => "Space".into(),
Input::Char(ch) => ch.to_string(),
Input::Ctrl(ch) => format!("Ctrl+{ch}"),
Input::Escape => "Esc".into(),
Input::Enter => "Enter".into(),
Input::Backspace => "Backspace".into(),
Input::Delete => "Delete".into(),
Input::Left => "Left".into(),
Input::Right => "Right".into(),
Input::Up => "Up".into(),
Input::Down => "Down".into(),
Input::Home => "Home".into(),
Input::End => "End".into(),
Input::Paste(_) => unreachable!("paste cannot be bound"),
}
}
fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
use Action::*;
let mut bindings: HashMap<Mode, HashMap<Input, Binding>> = HashMap::new();
@@ -432,6 +577,18 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.insert(key, Binding::Action(action));
}
}
for mode in [
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
] {
bindings
.entry(mode)
.or_default()
.insert(Input::Ctrl('p'), Binding::Action(Palette));
}
bindings
}
@@ -451,6 +608,7 @@ pub struct Editor {
capabilities: Vec<String>,
features: HashMap<String, Feature>,
bindings: HashMap<Mode, HashMap<Input, Binding>>,
palette: Option<Palette>,
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
@@ -485,6 +643,7 @@ impl Editor {
capabilities,
features: HashMap::new(),
bindings: default_bindings(),
palette: None,
undo: Vec::new(),
redo: Vec::new(),
insert_start: None,
@@ -515,6 +674,10 @@ impl Editor {
pub fn mode(&self) -> Mode {
self.mode
}
/// Read-only display state for the overlay; opening it does not change mode.
pub fn palette(&self) -> Option<&Palette> {
self.palette.as_ref()
}
/// Editable Ex/search prompt, without its `:` or `/` prefix.
pub fn command_line(&self) -> &str {
&self.command_line
@@ -610,6 +773,7 @@ impl Editor {
}
}
self.features.insert(feature.name.clone(), feature);
self.refresh_palette();
Ok(())
}
@@ -631,6 +795,7 @@ impl Editor {
});
}
}
self.refresh_palette();
self.set_message(format!("Plugin {name} disabled: {reason}"));
}
@@ -663,10 +828,134 @@ impl Editor {
}
};
self.bindings.entry(mode).or_default().insert(key, binding);
self.refresh_palette();
Ok(())
}
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
.bindings
.get(&self.mode)
.into_iter()
.flat_map(|keys| keys.iter())
.filter(|(_, binding)| match (action, binding) {
(Some(action), Binding::Action(bound)) => action == *bound,
(None, Binding::Command(bound)) => command == bound,
_ => false,
})
.map(|(input, _)| display_key(input))
.collect();
bindings.sort();
bindings
}
fn refresh_palette(&mut self) {
let Some(mut palette) = self.palette.take() else {
return;
};
let query = palette.query.to_lowercase();
// Derived labels contain the same searchable words as their names.
let matches = |name: &str, owner: &str| {
query
.split_whitespace()
.all(|token| name.contains(token) || owner.contains(token))
};
palette.entries.clear();
for &(name, action, modes) in CORE_ACTIONS {
if action == Action::Palette {
continue;
}
if !matches(name, "core") {
continue;
}
let mut label = name.trim_start_matches("core.").replace('_', " ");
label[..1].make_ascii_uppercase();
palette.entries.push(PaletteEntry {
name: name.into(),
label,
owner: "core".into(),
bindings: self.palette_bindings(Some(action), ""),
enabled: modes.contains(&self.mode),
modes: modes.to_vec(),
});
}
for feature in self.features.values() {
let owner = feature.name.to_lowercase();
for command in &feature.commands {
let name = command.to_lowercase();
if !matches(&name, &owner) {
continue;
}
palette.entries.push(PaletteEntry {
name: command.clone(),
label: command.clone(),
owner: feature.name.clone(),
bindings: self.palette_bindings(None, command),
enabled: self.mode == Mode::Normal,
modes: vec![Mode::Normal],
});
}
}
palette.entries.sort_by(|left, right| {
left.label
.cmp(&right.label)
.then_with(|| left.name.cmp(&right.name))
});
palette.selected = 0;
self.palette = Some(palette);
}
fn handle_palette(&mut self, input: Input) -> Option<Invocation> {
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
.get(palette.selected)
.filter(|entry| entry.enabled)?;
if let Some(action) = Action::named(self.mode, &entry.name) {
self.palette = None;
return self.act(action);
}
self.command_line = format!("{} ", entry.name);
self.palette = None;
self.mode = Mode::Command;
}
Input::Backspace => {
if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() {
palette.query.truncate(byte);
self.refresh_palette();
}
}
Input::Char(ch) if !ch.is_control() => {
palette.query.push(ch);
self.refresh_palette();
}
_ => {}
}
None
}
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
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.
if let Input::Paste(text) = input {
if self.mode == Mode::Insert {
@@ -709,6 +998,14 @@ impl Editor {
fn act(&mut self, action: Action) -> Option<Invocation> {
match action {
Action::Palette => {
self.palette = Some(Palette {
query: String::new(),
entries: Vec::new(),
selected: 0,
});
self.refresh_palette();
}
Action::Insert => self.begin_insert(),
Action::InsertFirstNonblank | Action::AppendLineEnd => {
if self.mode == Mode::Visual {
@@ -890,6 +1187,7 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.palette = None;
if self.mode == Mode::Insert {
self.finish_insert();
}
@@ -972,6 +1270,7 @@ impl Editor {
validate_text(text)?;
}
// Validation precedes even history/message changes, so a rejected response is atomic.
let previous_mode = self.mode;
let inserting = self.mode == Mode::Insert;
if inserting {
self.finish_insert();
@@ -1014,6 +1313,9 @@ impl Editor {
if inserting {
self.insert_start = Some(self.snapshot());
}
if self.mode != previous_mode {
self.refresh_palette();
}
Ok(())
}
@@ -1133,24 +1435,24 @@ impl Editor {
ordered.sort_by_key(|(_, _, range)| (range.start, range.end));
let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len());
for (target, primary, range) in ordered {
if let Some((previous, was_primary, previous_range)) = merged.last_mut() {
if ranges_intersect(*previous_range, range) {
let representative = if primary { target } else { *previous };
previous_range.start = previous_range.start.min(range.start);
previous_range.end = previous_range.end.max(range.end);
*previous = if self.mode == Mode::Visual
&& self.target_range(representative) != *previous_range
{
self.range_target(
*previous_range,
representative.active >= representative.anchor,
)
} else {
representative
};
*was_primary |= primary;
continue;
}
if let Some((previous, was_primary, previous_range)) = merged.last_mut()
&& ranges_intersect(*previous_range, range)
{
let representative = if primary { target } else { *previous };
previous_range.start = previous_range.start.min(range.start);
previous_range.end = previous_range.end.max(range.end);
*previous = if self.mode == Mode::Visual
&& self.target_range(representative) != *previous_range
{
self.range_target(
*previous_range,
representative.active >= representative.anchor,
)
} else {
representative
};
*was_primary |= primary;
continue;
}
merged.push((target, primary, range));
}
@@ -2141,6 +2443,416 @@ mod tests {
editor.normalize_targets();
}
fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry {
editor
.palette()
.unwrap()
.entries
.iter()
.find(|entry| entry.name == name)
.unwrap()
}
#[test]
fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() {
for mode in [
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
] {
for cancel in [Input::Escape, Input::Ctrl('p')] {
let mut editor = editor();
let original = "alpha beta\nalpha beta";
editor
.apply(vec![Effect::Replace(original.into())])
.unwrap();
editor.apply(vec![Effect::Insert("extra".into())]).unwrap();
editor.undo();
submit_search(&mut editor, "alpha");
set_carets(&mut editor, &[(0, 0), (1, 0)], 1);
match mode {
Mode::Normal => {}
Mode::Insert => {
editor.handle(Input::Char('i'));
type_text(&mut editor, "X");
}
Mode::Command => {
editor.handle(Input::Char(':'));
type_text(&mut editor, "w output");
}
Mode::Search => {
editor.handle(Input::Char('/'));
type_text(&mut editor, "alpha e\u{301}");
}
Mode::Visual => {
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
}
}
let snapshot =
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
let lines = editor.lines.clone();
let targets = editor.targets.clone();
let primary = editor.primary;
let selections = editor.selections();
let prompt = editor.command_line.clone();
let search = editor.search_query.clone();
let found = editor.search_match;
let message = editor.message.clone();
let undo: Vec<_> = editor.undo.iter().map(&snapshot).collect();
let redo: Vec<_> = editor.redo.iter().map(&snapshot).collect();
let insert = editor.insert_start.as_ref().map(snapshot);
editor.handle(Input::Ctrl('p'));
assert!(editor.palette().is_some());
type_text(&mut editor, "query Δe\u{301}👩‍🚀 ");
editor.handle(Input::Backspace);
editor.handle(Input::Backspace);
assert_eq!(editor.palette().unwrap().query, "query Δe\u{301}");
editor.handle(Input::Paste("ignored\n:q!".into()));
editor.handle(Input::Down);
editor.handle(Input::Ctrl('k'));
editor.handle(cancel);
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), mode);
assert_eq!(editor.lines, lines);
assert_eq!(editor.targets, targets);
assert_eq!(editor.primary, primary);
assert_eq!(editor.selections(), selections);
assert_eq!(editor.command_line, prompt);
assert_eq!(editor.search_query, search);
assert_eq!(editor.search_match, found);
assert_eq!(editor.message, message);
assert_eq!(editor.undo.iter().map(&snapshot).collect::<Vec<_>>(), undo);
assert_eq!(editor.redo.iter().map(&snapshot).collect::<Vec<_>>(), redo);
assert_eq!(editor.insert_start.as_ref().map(snapshot), insert);
if mode == Mode::Insert {
type_text(&mut editor, "Y");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, original);
}
}
}
}
#[test]
fn palette_filters_all_tokens_and_wraps_without_executing_empty_results() {
let mut editor = editor();
editor
.register(plugin("Älpha Tools", &["greet_one", "greet_two"]))
.unwrap();
editor.handle(Input::Ctrl('p'));
let order: Vec<_> = editor
.palette()
.unwrap()
.entries
.iter()
.map(|entry| (entry.label.clone(), entry.name.clone()))
.collect();
assert!(order.windows(2).all(|pair| pair[0] <= pair[1]));
assert!(
!editor
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.name == "core.palette")
);
type_text(&mut editor, "GREET ÄLPHA");
assert_eq!(editor.palette().unwrap().entries.len(), 2);
assert_eq!(editor.palette().unwrap().entries[0].name, "greet_one");
editor.handle(Input::Up);
assert_eq!(editor.palette().unwrap().selected, 1);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.palette().unwrap().selected, 0);
editor.handle(Input::Down);
assert_eq!(editor.palette().unwrap().selected, 1);
editor.handle(Input::Ctrl('k'));
assert_eq!(editor.palette().unwrap().selected, 0);
editor.handle(Input::Down);
editor.handle(Input::Char(' '));
assert_eq!(editor.palette().unwrap().selected, 0);
type_text(&mut editor, "missing");
assert!(editor.palette().unwrap().entries.is_empty());
for input in [
Input::Enter,
Input::Up,
Input::Down,
Input::Ctrl('k'),
Input::Ctrl('n'),
] {
assert_eq!(editor.handle(input), None);
assert_eq!(editor.palette().unwrap().selected, 0);
}
while !editor.palette().unwrap().query.is_empty() {
editor.handle(Input::Backspace);
}
type_text(&mut editor, "e\u{301}👩‍🚀");
editor.handle(Input::Backspace);
assert_eq!(editor.palette().unwrap().query, "e\u{301}");
editor.handle(Input::Backspace);
assert_eq!(editor.palette().unwrap().query, "");
assert!(!editor.palette().unwrap().entries.is_empty());
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.palette().unwrap().query, "");
}
#[test]
fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() {
let mut names = HashSet::new();
for &(name, action, modes) in CORE_ACTIONS {
assert!(names.insert(name));
for mode in [
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
] {
assert!(Action::named(mode, name) == modes.contains(&mode).then_some(action));
}
}
for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] {
let mut editor = editor();
editor.mode = mode;
editor.command_line = "untouched".into();
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.undo");
let entry = palette_entry(&editor, "core.undo");
assert!(!entry.enabled);
assert_eq!(entry.modes, vec![Mode::Normal]);
assert!(entry.bindings.is_empty());
assert_eq!(editor.handle(Input::Enter), None);
assert!(editor.palette().is_some());
assert_eq!(editor.mode(), mode);
assert_eq!(editor.command_line(), "untouched");
while !editor.palette().unwrap().query.is_empty() {
editor.handle(Input::Backspace);
}
type_text(&mut editor, "q!");
assert_eq!(editor.palette().unwrap().entries.len(), 1);
assert!(!palette_entry(&editor, "q!").enabled);
editor.handle(Input::Enter);
assert!(!editor.should_quit());
assert!(editor.palette().is_some());
}
}
#[test]
fn palette_effective_bindings_refresh_with_canonical_terminal_names() {
let mut editor = editor();
editor.handle(Input::Ctrl('p'));
assert_eq!(palette_entry(&editor, "core.insert").bindings, vec!["i"]);
editor.bind("normal", "i", None).unwrap();
assert!(palette_entry(&editor, "core.insert").bindings.is_empty());
for key in [
"shift+i", "ctrl+m", "ctrl+h", "ctrl+i", "space", "ctrl+r", "界",
] {
editor
.bind("normal", key, Some("core.insert".into()))
.unwrap();
}
editor
.bind("normal", "ctrl+j", Some("core.insert".into()))
.unwrap();
assert_eq!(
palette_entry(&editor, "core.insert").bindings,
vec!["Backspace", "Ctrl+r", "Enter", "I", "Space", "Tab", "界"]
);
assert!(
palette_entry(&editor, "core.insert_first_nonblank")
.bindings
.is_empty()
);
editor.bind("normal", "ENTER", None).unwrap();
assert!(
!palette_entry(&editor, "core.insert")
.bindings
.iter()
.any(|key| key == "Enter")
);
editor
.bind("normal", "界", Some("core.undo".into()))
.unwrap();
assert_eq!(
palette_entry(&editor, "core.undo").bindings,
vec!["u", "界"]
);
assert!(
!palette_entry(&editor, "core.insert")
.bindings
.iter()
.any(|key| key == "界")
);
assert!(
editor
.bind("insert", "g", Some("core.undo".into()))
.is_err()
);
editor.handle(Input::Escape);
editor.bind("normal", "ctrl+p", None).unwrap();
editor
.bind("normal", "ctrl+f", Some("core.palette".into()))
.unwrap();
editor.handle(Input::Ctrl('p'));
assert!(editor.palette().is_none());
editor.handle(Input::Ctrl('f'));
assert!(editor.palette().is_some());
editor.handle(Input::Ctrl('p'));
assert!(editor.palette().is_none());
}
#[test]
fn palette_registered_commands_refresh_and_retire_with_plugins() {
let mut editor = editor();
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "fixture");
assert!(editor.palette().unwrap().entries.is_empty());
editor.register(plugin("fixture", &["greet"])).unwrap();
assert_eq!(editor.palette().unwrap().entries.len(), 1);
assert_eq!(palette_entry(&editor, "greet").owner, "fixture");
assert!(palette_entry(&editor, "greet").enabled);
editor.bind("normal", "g", Some("greet".into())).unwrap();
assert_eq!(palette_entry(&editor, "greet").bindings, vec!["g"]);
editor.disable_plugin("fixture", "retired");
assert!(editor.palette().unwrap().entries.is_empty());
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
editor.handle(Input::Escape);
assert_eq!(editor.handle(Input::Char('g')), None);
assert!(editor.bind("normal", "g", Some("greet".into())).is_err());
}
#[test]
fn palette_commands_require_prompt_submission_and_dispatch_arguments_once() {
let mut editor = editor();
editor.register(plugin("fixture", &["greet"])).unwrap();
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "greet");
assert_eq!(editor.handle(Input::Enter), None);
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "greet ");
type_text(&mut editor, "界!");
assert_eq!(
editor.handle(Input::Enter),
Some(Invocation {
plugin: "fixture".into(),
command: "greet".into(),
args: "界!".into(),
})
);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.handle(Input::Enter), None);
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "q!");
editor.handle(Input::Enter);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit());
editor.handle(Input::Enter);
assert!(editor.should_quit());
}
#[test]
fn palette_actions_dispatch_visual_change_insert_newline_and_original_prompts() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("alpha beta".into())])
.unwrap();
set_ranges(
&mut editor,
&[(Cursor::default(), Cursor { row: 0, column: 5 })],
0,
);
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.change");
assert!(palette_entry(&editor, "core.change").enabled);
editor.handle(Input::Enter);
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.context().text, " beta");
type_text(&mut editor, "Ω");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "Ω beta");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "alpha beta");
let mut insertion = self::editor();
insertion.handle(Input::Char('i'));
type_text(&mut insertion, "A");
insertion.handle(Input::Ctrl('p'));
type_text(&mut insertion, "core.newline");
insertion.handle(Input::Enter);
assert_eq!(insertion.mode(), Mode::Insert);
assert_eq!(insertion.lines(), &["A", ""]);
type_text(&mut insertion, "B");
insertion.handle(Input::Escape);
insertion.handle(Input::Char('u'));
assert_eq!(insertion.context().text, "");
insertion.handle(Input::Char('U'));
assert_eq!(insertion.context().text, "A\nB");
editor.handle(Input::Char(':'));
type_text(&mut editor, "plugins");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.submit");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Normal);
assert!(editor.message().starts_with("Capabilities: "));
assert_eq!(editor.command_line(), "");
editor.handle(Input::Char('/'));
type_text(&mut editor, "beta");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.submit");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.search_query, "beta");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 });
}
#[test]
fn palette_survives_rejected_effects_but_external_execution_closes_it() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("alpha beta".into())])
.unwrap();
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
let targets = editor.targets.clone();
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.change");
let undo = editor.undo.len();
assert!(
editor
.apply(vec![
Effect::Message("changed".into()),
Effect::Insert("\0".into())
])
.is_err()
);
assert_eq!(editor.palette().unwrap().query, "core.change");
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.targets, targets);
assert_eq!(editor.undo.len(), undo);
editor.apply(vec![Effect::Message("ok".into())]).unwrap();
assert_eq!(editor.palette().unwrap().query, "core.change");
assert_eq!(editor.targets, targets);
editor.execute("plugins");
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.change");
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
assert_eq!(editor.mode(), Mode::Normal);
assert!(!palette_entry(&editor, "core.change").enabled);
}
#[test]
fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() {
let mut editor = editor();
+1
View File
@@ -7,6 +7,7 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept, Esc cancel
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
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
+1 -1
View File
@@ -25,7 +25,7 @@ module Vamp
%w[
insert insert_first_nonblank append append_line_end open_below open_above
left right down up join_lines line_start line_end delete undo redo command
normal newline backspace submit
normal newline backspace submit palette
search search_next search_previous
word_next word_previous word_end big_word_next big_word_previous big_word_end
select copy cut change paste_before paste_after
+63
View File
@@ -155,6 +155,69 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
}
}
#[test]
fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
fixture.file(
"palette.rb",
r#"
Vamp.plugin "palette_fixture", requires: ["text"]
Vamp.command("palette_insert") { |editor, args| editor.insert args }
Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "palette.rb"
config.bind "ctrl+p", nil
config.bind "ctrl+f", config.core.palette
end
"#,
);
session.handle(Input::Ctrl('p'));
assert!(session.editor().palette().is_none());
session.handle(Input::Ctrl('f'));
for ch in "palette_insert".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
session.editor().palette().unwrap().entries[0].owner,
"palette_fixture"
);
session.handle(Input::Enter);
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
assert_eq!(session.editor().command_line(), "palette_insert ");
assert_eq!(session.editor().lines(), &["base"]);
for ch in "界!".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
assert_eq!(session.editor().lines(), &["界!base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Ctrl('f'));
for ch in "palette_fail".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
session.handle(Input::Enter);
assert!(session.editor().message().contains("disabled"));
session.handle(Input::Ctrl('f'));
assert!(
!session
.editor()
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.owner == "palette_fixture")
);
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["base"]);
}
#[test]
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
let fixture = Fixture::new();
+313 -3
View File
@@ -17,15 +17,15 @@ use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
style::{Color, Style},
style::{Color, Modifier, Style},
text::Line,
widgets::Paragraph,
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Input, Mode},
editor::{Editor, Input, Mode, Palette},
session::EditorSession,
};
@@ -339,12 +339,322 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
));
}
}
if let Some(palette) = editor.palette() {
draw_palette(frame, palette, editor.mode());
}
}
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(16)),
Constraint::Fill(1),
])
.areas(column);
frame.render_widget(Clear, panel);
let bordered = panel.height >= 4 && panel.width >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.title(format!(" Commands · {mode} "));
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [query_area, results_area, help_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 4)),
])
.areas(content);
let query = format!("> {}", palette.query);
let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width);
let offset = query_width.saturating_sub(width - 1);
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width))
.style(Style::default().fg(Color::Yellow)),
query_area,
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() {
frame.render_widget(Paragraph::new("No matching commands"), results_area);
} else if results_area.height > 0 {
let row_height = usize::from(results_area.height >= 2) + 1;
let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1);
let mut lines = Vec::with_capacity(count * row_height);
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
let mut style = Style::default();
if !entry.enabled {
style = style.add_modifier(Modifier::DIM);
}
if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
}
let bindings = if entry.bindings.is_empty() {
"unbound".into()
} else {
entry.bindings.join(", ")
};
let modes = entry
.modes
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("/");
let metadata = format!("{} | {bindings} | {modes}", entry.owner);
let label = format!(
"{} {} ({})",
if index == palette.selected { ">" } else { " " },
entry.label,
entry.name
);
if row_height == 2 {
lines.push(Line::from(label).style(style));
lines.push(Line::from(format!(" {metadata}")).style(style));
} else {
lines.push(Line::from(format!("{label} | {metadata}")).style(style));
}
}
frame.render_widget(Paragraph::new(lines), results_area);
}
frame.render_widget(
Paragraph::new(format!(
"{mode} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
)),
help_area,
);
}
#[cfg(test)]
mod tests {
use super::*;
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
let buffer = terminal.backend().buffer();
let area = buffer.area;
(area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('/'));
for ch in "saved search".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('p'));
for ch in "no-such-command".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("> no-such-command"), "{text}");
assert!(text.contains("No matching commands"), "{text}");
assert!(text.contains("SEARCH"), "{text}");
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Search);
assert_eq!(editor.command_line(), "saved search");
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(screen_text(&terminal).contains("/saved search"));
}
#[test]
fn palette_window_follows_keyboard_selection() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Ctrl('p'));
for _ in 0..20 {
editor.handle(Input::Down);
}
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Ctrl('n'));
let palette = editor.palette().unwrap();
assert_eq!(palette.selected, 20);
let selected = &palette.entries[palette.selected];
let first_name = palette.entries[0].name.clone();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&selected.name), "{text}");
assert!(!text.contains(&format!("({first_name})")), "{text}");
let buffer = terminal.backend().buffer();
let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
}
#[test]
fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.register(crate::editor::Feature {
name: "sample".into(),
required_capabilities: vec![],
commands: vec!["greet".into()],
core: false,
})
.unwrap();
editor.bind("normal", "g", Some("greet".into())).unwrap();
editor.bind("normal", "g", None).unwrap();
editor
.bind("normal", "ctrl+x", Some("greet".into()))
.unwrap();
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}");
assert!(!text.contains("unbound"), "{text}");
editor.handle(Input::Escape);
editor.handle(Input::Char('i'));
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | unbound | NORMAL"), "{text}");
assert!(text.contains("INSERT"), "{text}");
let cell = &terminal.backend().buffer()[(1, 2)];
assert!(cell.modifier.contains(Modifier::DIM));
assert_eq!(cell.bg, Color::Blue);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
assert_eq!(editor.mode(), Mode::Insert);
}
#[test]
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
let palette = Palette {
query: "long\t界e\u{301}z".into(),
entries: vec![],
selected: 0,
};
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
let area = ratatui::layout::Rect::new(3, 2, width, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(24, 12),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| draw_palette(frame, &palette, Mode::Visual))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let bordered = height >= 4 && width >= 4;
let query_x = area.x + u16::from(bordered);
let query_y = area.y + u16::from(bordered);
let query_width = width - 2 * u16::from(bordered);
assert_eq!(cursor.x, query_x + query_width - 1);
assert_eq!(cursor.y, query_y);
let query = format!("> {}", palette.query);
let offset =
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(
query_x,
query_y,
query_width,
1,
));
use ratatui::widgets::Widget;
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
.render(expected.area, &mut expected);
for x in query_x..query_x + query_width {
assert_eq!(
terminal.backend().buffer()[(x, query_y)].symbol(),
expected[(x, query_y)].symbol()
);
}
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
}
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_palette(frame, &palette, Mode::Normal))
.unwrap();
}
}
#[test]
fn opening_palette_does_not_scroll_underlying_editor_viewport() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"first\nsecond\n0123456789界z".into(),
)])
.unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let viewport = (top, left);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!((top, left), viewport);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!((top, left), viewport);
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"z"
);
}
#[test]
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();