feat: add searchable command palette
This commit is contained in:
@@ -17,6 +17,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
|||||||
|
|
||||||
| Mode | Keys |
|
| Mode | Keys |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| All modes | Ctrl+p opens the command palette; type to filter, Up/Down or Ctrl+k/Ctrl+n select, Enter accepts, Esc/Ctrl+p cancels |
|
||||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||||
@@ -62,11 +63,21 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma
|
|||||||
|
|
||||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||||
|
|
||||||
|
### Command palette
|
||||||
|
|
||||||
|
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||||
|
|
||||||
|
Search is case-insensitive: every whitespace-separated query token must occur in the command name, human-readable label, or owner. Results have a stable label/name order. Up/Down or Ctrl+k/Ctrl+n wrap through results; Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||||
|
|
||||||
|
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. Accepting an Ex/plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Choosing `q!` therefore does not immediately discard changes.
|
||||||
|
|
||||||
|
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; navigation and cancellation inside the palette are fixed. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||||
|
|
||||||
## Module seams
|
## Module seams
|
||||||
|
|
||||||
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
||||||
|
|
||||||
- `editor`: owns buffer, the primary cursor and selection set, modes and internal clipboard fragments, search state/navigation and grapheme-coordinate range presentation, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns buffer, the primary cursor and selection set, modes and internal clipboard fragments, search state/navigation and grapheme-coordinate range presentation, action metadata and command-palette state/filtering, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.
|
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.
|
||||||
- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
||||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||||
@@ -74,6 +85,7 @@ One crate, four modules, no workspace or frontend-specific types in the editing
|
|||||||
|
|
||||||
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
||||||
|
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
|
||||||
|
|
||||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||||
|
|
||||||
@@ -126,7 +138,7 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
|
|||||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||||
| Normal, Insert | `delete` |
|
| Normal, Insert | `delete` |
|
||||||
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
||||||
| Normal, Insert, Command, Search, Visual | `normal` |
|
| Normal, Insert, Command, Search, Visual | `normal`, `palette` |
|
||||||
| Insert, Command, Search | `backspace` |
|
| Insert, Command, Search | `backspace` |
|
||||||
| Insert | `newline` |
|
| Insert | `newline` |
|
||||||
| Command, Search | `submit` |
|
| Command, Search | `submit` |
|
||||||
|
|||||||
+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)]
|
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct Cursor {
|
pub struct Cursor {
|
||||||
pub row: usize,
|
pub row: usize,
|
||||||
@@ -140,7 +157,7 @@ struct Edit<'a> {
|
|||||||
round_up: bool,
|
round_up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
enum Action {
|
enum Action {
|
||||||
Insert,
|
Insert,
|
||||||
InsertFirstNonblank,
|
InsertFirstNonblank,
|
||||||
@@ -184,58 +201,166 @@ enum Action {
|
|||||||
SelectNext,
|
SelectNext,
|
||||||
SkipOccurrence,
|
SkipOccurrence,
|
||||||
SelectAll,
|
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 {
|
impl Action {
|
||||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||||
use Action::*;
|
CORE_ACTIONS
|
||||||
Some(match (mode, name) {
|
.iter()
|
||||||
(Mode::Normal, "core.insert") => Insert,
|
.find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode))
|
||||||
(Mode::Normal | Mode::Visual, "core.insert_first_nonblank") => InsertFirstNonblank,
|
.map(|(_, action, _)| *action)
|
||||||
(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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>> {
|
fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||||
use Action::*;
|
use Action::*;
|
||||||
let mut bindings: HashMap<Mode, HashMap<Input, Binding>> = HashMap::new();
|
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));
|
.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
|
bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +608,7 @@ pub struct Editor {
|
|||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
features: HashMap<String, Feature>,
|
features: HashMap<String, Feature>,
|
||||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
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.
|
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
|
||||||
undo: Vec<Snapshot>,
|
undo: Vec<Snapshot>,
|
||||||
redo: Vec<Snapshot>,
|
redo: Vec<Snapshot>,
|
||||||
@@ -485,6 +643,7 @@ impl Editor {
|
|||||||
capabilities,
|
capabilities,
|
||||||
features: HashMap::new(),
|
features: HashMap::new(),
|
||||||
bindings: default_bindings(),
|
bindings: default_bindings(),
|
||||||
|
palette: None,
|
||||||
undo: Vec::new(),
|
undo: Vec::new(),
|
||||||
redo: Vec::new(),
|
redo: Vec::new(),
|
||||||
insert_start: None,
|
insert_start: None,
|
||||||
@@ -515,6 +674,10 @@ impl Editor {
|
|||||||
pub fn mode(&self) -> Mode {
|
pub fn mode(&self) -> 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.
|
/// Editable Ex/search prompt, without its `:` or `/` prefix.
|
||||||
pub fn command_line(&self) -> &str {
|
pub fn command_line(&self) -> &str {
|
||||||
&self.command_line
|
&self.command_line
|
||||||
@@ -610,6 +773,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.features.insert(feature.name.clone(), feature);
|
self.features.insert(feature.name.clone(), feature);
|
||||||
|
self.refresh_palette();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +795,7 @@ impl Editor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.refresh_palette();
|
||||||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,10 +828,134 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.bindings.entry(mode).or_default().insert(key, binding);
|
self.bindings.entry(mode).or_default().insert(key, binding);
|
||||||
|
self.refresh_palette();
|
||||||
Ok(())
|
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> {
|
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.
|
// Paste is literal input, never a configurable key or command.
|
||||||
if let Input::Paste(text) = input {
|
if let Input::Paste(text) = input {
|
||||||
if self.mode == Mode::Insert {
|
if self.mode == Mode::Insert {
|
||||||
@@ -709,6 +998,14 @@ impl Editor {
|
|||||||
|
|
||||||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||||||
match action {
|
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::Insert => self.begin_insert(),
|
||||||
Action::InsertFirstNonblank | Action::AppendLineEnd => {
|
Action::InsertFirstNonblank | Action::AppendLineEnd => {
|
||||||
if self.mode == Mode::Visual {
|
if self.mode == Mode::Visual {
|
||||||
@@ -890,6 +1187,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||||||
|
self.palette = None;
|
||||||
if self.mode == Mode::Insert {
|
if self.mode == Mode::Insert {
|
||||||
self.finish_insert();
|
self.finish_insert();
|
||||||
}
|
}
|
||||||
@@ -972,6 +1270,7 @@ impl Editor {
|
|||||||
validate_text(text)?;
|
validate_text(text)?;
|
||||||
}
|
}
|
||||||
// Validation precedes even history/message changes, so a rejected response is atomic.
|
// Validation precedes even history/message changes, so a rejected response is atomic.
|
||||||
|
let previous_mode = self.mode;
|
||||||
let inserting = self.mode == Mode::Insert;
|
let inserting = self.mode == Mode::Insert;
|
||||||
if inserting {
|
if inserting {
|
||||||
self.finish_insert();
|
self.finish_insert();
|
||||||
@@ -1014,6 +1313,9 @@ impl Editor {
|
|||||||
if inserting {
|
if inserting {
|
||||||
self.insert_start = Some(self.snapshot());
|
self.insert_start = Some(self.snapshot());
|
||||||
}
|
}
|
||||||
|
if self.mode != previous_mode {
|
||||||
|
self.refresh_palette();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,24 +1435,24 @@ impl Editor {
|
|||||||
ordered.sort_by_key(|(_, _, range)| (range.start, range.end));
|
ordered.sort_by_key(|(_, _, range)| (range.start, range.end));
|
||||||
let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len());
|
let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len());
|
||||||
for (target, primary, range) in ordered {
|
for (target, primary, range) in ordered {
|
||||||
if let Some((previous, was_primary, previous_range)) = merged.last_mut() {
|
if let Some((previous, was_primary, previous_range)) = merged.last_mut()
|
||||||
if ranges_intersect(*previous_range, range) {
|
&& ranges_intersect(*previous_range, range)
|
||||||
let representative = if primary { target } else { *previous };
|
{
|
||||||
previous_range.start = previous_range.start.min(range.start);
|
let representative = if primary { target } else { *previous };
|
||||||
previous_range.end = previous_range.end.max(range.end);
|
previous_range.start = previous_range.start.min(range.start);
|
||||||
*previous = if self.mode == Mode::Visual
|
previous_range.end = previous_range.end.max(range.end);
|
||||||
&& self.target_range(representative) != *previous_range
|
*previous = if self.mode == Mode::Visual
|
||||||
{
|
&& self.target_range(representative) != *previous_range
|
||||||
self.range_target(
|
{
|
||||||
*previous_range,
|
self.range_target(
|
||||||
representative.active >= representative.anchor,
|
*previous_range,
|
||||||
)
|
representative.active >= representative.anchor,
|
||||||
} else {
|
)
|
||||||
representative
|
} else {
|
||||||
};
|
representative
|
||||||
*was_primary |= primary;
|
};
|
||||||
continue;
|
*was_primary |= primary;
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
merged.push((target, primary, range));
|
merged.push((target, primary, range));
|
||||||
}
|
}
|
||||||
@@ -2141,6 +2443,416 @@ mod tests {
|
|||||||
editor.normalize_targets();
|
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]
|
#[test]
|
||||||
fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() {
|
fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
|||||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||||
|
|
||||||
Default keys (configurable with config.bind):
|
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
|
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
|
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
|
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ module Vamp
|
|||||||
%w[
|
%w[
|
||||||
insert insert_first_nonblank append append_line_end open_below open_above
|
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
|
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
|
search search_next search_previous
|
||||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
||||||
select copy cut change paste_before paste_after
|
select copy cut change paste_before paste_after
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
|
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
+313
-3
@@ -17,15 +17,15 @@ use ratatui::{
|
|||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Layout},
|
layout::{Constraint, Layout},
|
||||||
style::{Color, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Line,
|
text::Line,
|
||||||
widgets::Paragraph,
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
};
|
};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
editor::{Editor, Input, Mode},
|
editor::{Editor, Input, Mode, Palette},
|
||||||
session::EditorSession,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
|
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
|
||||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user