feat: add searchable command palette
This commit is contained in:
+779
-67
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user