feat: add configurable Ctrl+j/k list navigation
This commit is contained in:
@@ -17,7 +17,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
||||
|
||||
| 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 |
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal, Visual | Space o opens floating file search; Space e opens/focuses the docked explorer |
|
||||
@@ -30,7 +30,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selections; `p`/`P` paste internal clipboard after/before each caret; Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below (Ctrl+j aliases Enter in terminals) |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets |
|
||||
@@ -99,7 +99,7 @@ The dock defaults to the right of the editor workspace. Set `config.file_explore
|
||||
| File-search input | Behavior |
|
||||
| --- | --- |
|
||||
| Type / Backspace | Case-insensitive whitespace-token filtering / remove one whole grapheme |
|
||||
| Up/Down or Ctrl+k/Ctrl+n | Wrap through matching files |
|
||||
| Ctrl+j/Down/Ctrl+n or Ctrl+k/Up | Wrap forward or backward through matching files |
|
||||
| Enter | Open the selected file, reusing an existing buffer without reloading hidden edits |
|
||||
| Esc | Close floating search; return to editor focus while retaining a dock |
|
||||
| Tab | Return to editor focus from a dock |
|
||||
@@ -115,11 +115,25 @@ The project root is the working directory captured at startup. Scanning uses the
|
||||
|
||||
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.
|
||||
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. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. 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.
|
||||
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`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||
|
||||
### Selection-list bindings
|
||||
|
||||
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+b", config.core.up, mode: config.modes.selection
|
||||
config.bind "ctrl+j", nil, mode: config.modes.selection
|
||||
```
|
||||
|
||||
Selection bindings take precedence only while a list is focused. Unbound printable keys still filter; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the underlying mode; Enter remains accept by default. Both list footers show the effective navigation keys, including remaps and disabled directions.
|
||||
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently.
|
||||
|
||||
### Leader key
|
||||
|
||||
@@ -145,6 +159,7 @@ One crate, five modules, no workspace or terminal-coordinate types in the editin
|
||||
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 `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.
|
||||
Frontends use the cached `selection_navigation_hint()` to advertise the current shared list bindings without reconstructing input policy.
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
@@ -183,7 +198,7 @@ Vamp.configure do |config|
|
||||
end
|
||||
```
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; available modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Omitting `mode` selects Normal. The leader is session-wide, defaults to `"space"`, and is applied before configured bindings.
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures focused list navigation. Omitting `mode` selects Normal. The leader is session-wide, defaults to `"space"`, and is applied before configured bindings.
|
||||
`config.file_explorer_side` is session-wide and defaults to `"right"`; the other choices are `"left"`, `"top"`, and `"bottom"`. An invalid side reports a notice and retains the default, while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
@@ -194,7 +209,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ctrl+m` with `enter`; these names address the same binding, including inside leader sequences. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+m` with `enter`; these names address the same binding, including inside leader sequences. Ctrl+j is a distinct control key, not an Enter alias. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
| Available modes | Methods on `config.core` |
|
||||
| --- | --- |
|
||||
@@ -204,7 +219,8 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual | `left`, `right`, `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Selection | `down`, `up` |
|
||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Normal, Insert | `delete` |
|
||||
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.file_explorer_side = "right"
|
||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
|
||||
+400
-32
@@ -445,7 +445,7 @@ fn control_key(ch: char) -> Input {
|
||||
match ch.to_ascii_lowercase() {
|
||||
'h' => Input::Backspace,
|
||||
'i' => Input::Char('\t'),
|
||||
'j' | 'm' => Input::Enter,
|
||||
'm' => Input::Enter,
|
||||
ch => Input::Ctrl(ch),
|
||||
}
|
||||
}
|
||||
@@ -590,6 +590,14 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
Submit
|
||||
},
|
||||
),
|
||||
(
|
||||
Input::Ctrl('j'),
|
||||
if mode == Mode::Insert {
|
||||
Newline
|
||||
} else {
|
||||
Submit
|
||||
},
|
||||
),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
@@ -698,6 +706,8 @@ pub struct Editor {
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||||
selection_bindings: HashMap<Input, Binding>,
|
||||
selection_navigation_hint: String,
|
||||
leader: Input,
|
||||
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
|
||||
leader_suffix: Vec<Input>,
|
||||
@@ -723,6 +733,17 @@ impl Editor {
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
bindings: default_bindings(),
|
||||
selection_bindings: [
|
||||
(Input::Up, Action::Up),
|
||||
(Input::Ctrl('k'), Action::Up),
|
||||
(Input::Down, Action::Down),
|
||||
(Input::Ctrl('j'), Action::Down),
|
||||
(Input::Ctrl('n'), Action::Down),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, action)| (key, Binding::Action(action)))
|
||||
.collect(),
|
||||
selection_navigation_hint: String::new(),
|
||||
leader: Input::Char(' '),
|
||||
leader_bindings: HashMap::new(),
|
||||
leader_suffix: Vec::new(),
|
||||
@@ -733,6 +754,7 @@ impl Editor {
|
||||
file_explorer_side: DockSide::Right,
|
||||
count: None,
|
||||
};
|
||||
editor.refresh_selection_navigation_hint();
|
||||
editor
|
||||
.install(
|
||||
Feature {
|
||||
@@ -1005,9 +1027,21 @@ impl Editor {
|
||||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||||
}
|
||||
|
||||
/// Overrides one mode's single key or symbolic `leader` sequence. `None`
|
||||
/// disables it. Invalid overrides leave existing bindings intact.
|
||||
/// Overrides an editing mode's key/leader route, or a single `selection` key
|
||||
/// for list navigation. `None` disables it; invalid overrides are atomic.
|
||||
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
||||
if mode == "selection" {
|
||||
let input = parse_key(key)?;
|
||||
let binding = match target.as_deref() {
|
||||
None => Binding::Disabled,
|
||||
Some("core.up") => Binding::Action(Action::Up),
|
||||
Some("core.down") => Binding::Action(Action::Down),
|
||||
Some(target) => return Err(format!("Unknown selection action: {target}")),
|
||||
};
|
||||
self.selection_bindings.insert(input, binding);
|
||||
self.refresh_selection_navigation_hint();
|
||||
return Ok(());
|
||||
}
|
||||
let mode = match mode {
|
||||
"normal" => Mode::Normal,
|
||||
"insert" => Mode::Insert,
|
||||
@@ -1077,6 +1111,58 @@ impl Editor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn selection_navigation_hint(&self) -> &str {
|
||||
&self.selection_navigation_hint
|
||||
}
|
||||
|
||||
fn refresh_selection_navigation_hint(&mut self) {
|
||||
let keys = |action| {
|
||||
let mut keys: Vec<_> = self
|
||||
.selection_bindings
|
||||
.iter()
|
||||
.filter(
|
||||
|(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action),
|
||||
)
|
||||
.map(|(input, _)| display_key(input))
|
||||
.collect();
|
||||
keys.sort();
|
||||
if keys.is_empty() {
|
||||
"unbound".into()
|
||||
} else {
|
||||
keys.join("/")
|
||||
}
|
||||
};
|
||||
self.selection_navigation_hint = format!(
|
||||
"{} next · {} previous",
|
||||
keys(Action::Down),
|
||||
keys(Action::Up)
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_selection_binding(&mut self, input: &Input) -> bool {
|
||||
let next = match self.selection_bindings.get(input) {
|
||||
Some(Binding::Action(Action::Down)) => true,
|
||||
Some(Binding::Action(Action::Up)) => false,
|
||||
Some(Binding::Disabled) => return true,
|
||||
None => return false,
|
||||
_ => unreachable!("selection bindings only accept navigation or disabled"),
|
||||
};
|
||||
if let Some(picker) = self.file_picker.as_mut().filter(|picker| picker.focused) {
|
||||
picker.navigate(next);
|
||||
} else if let Some(palette) = self.palette.as_mut()
|
||||
&& !palette.entries.is_empty()
|
||||
{
|
||||
palette.selected = if next {
|
||||
(palette.selected + 1) % palette.entries.len()
|
||||
} else if palette.selected == 0 {
|
||||
palette.entries.len() - 1
|
||||
} else {
|
||||
palette.selected - 1
|
||||
};
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn palette_bindings(&self, action: Option<Action>, command: &str) -> Vec<String> {
|
||||
// ponytail: scan mode bindings per result; index by target if catalogs grow.
|
||||
let mut bindings: Vec<_> = self
|
||||
@@ -1180,16 +1266,6 @@ impl Editor {
|
||||
let palette = self.palette.as_mut().expect("open palette receives input");
|
||||
match input {
|
||||
Input::Escape | Input::Ctrl('p') => self.palette = None,
|
||||
Input::Up | Input::Ctrl('k') if !palette.entries.is_empty() => {
|
||||
palette.selected = if palette.selected == 0 {
|
||||
palette.entries.len() - 1
|
||||
} else {
|
||||
palette.selected - 1
|
||||
};
|
||||
}
|
||||
Input::Down | Input::Ctrl('n') if !palette.entries.is_empty() => {
|
||||
palette.selected = (palette.selected + 1) % palette.entries.len();
|
||||
}
|
||||
Input::Enter => {
|
||||
let entry = palette
|
||||
.entries
|
||||
@@ -1260,8 +1336,6 @@ impl Editor {
|
||||
Input::Char('\t') if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => picker.navigate(false),
|
||||
Input::Down | Input::Ctrl('n') => picker.navigate(true),
|
||||
Input::Ctrl('r') => picker.refresh(),
|
||||
Input::Backspace => picker.backspace(),
|
||||
Input::Char(ch) if !ch.is_control() => {
|
||||
@@ -1292,23 +1366,23 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||||
if self
|
||||
// Normalize genuine terminal aliases before list and editing dispatch.
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
let picker_focused = self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
.is_some_and(|picker| picker.focused);
|
||||
if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) {
|
||||
return None;
|
||||
}
|
||||
if picker_focused {
|
||||
self.handle_file_picker(input);
|
||||
return None;
|
||||
}
|
||||
if self.palette.is_some() {
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
return self.handle_palette(input);
|
||||
}
|
||||
// Paste is literal input, never a configurable key or command.
|
||||
@@ -1320,11 +1394,6 @@ impl Editor {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// Normalize terminal aliases for every frontend.
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.leader_pending().is_some() {
|
||||
if input == Input::Escape {
|
||||
self.cancel_leader();
|
||||
@@ -3122,6 +3191,288 @@ mod tests {
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_navigation_wraps_without_touching_any_underlying_mode() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a.txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "file text").unwrap();
|
||||
}
|
||||
for surface in [
|
||||
None,
|
||||
Some(FilePresentation::Floating),
|
||||
Some(FilePresentation::Docked),
|
||||
] {
|
||||
for mode in [
|
||||
Mode::Normal,
|
||||
Mode::Insert,
|
||||
Mode::Command,
|
||||
Mode::Search,
|
||||
Mode::Visual,
|
||||
] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("alpha\nbeta".into())])
|
||||
.unwrap();
|
||||
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
|
||||
editor.active.mode = mode;
|
||||
editor.command_line = "original prompt".into();
|
||||
if let Some(presentation) = surface {
|
||||
editor.open_file_picker(presentation);
|
||||
type_text(&mut editor, "txt");
|
||||
} else {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "core");
|
||||
}
|
||||
let list = |editor: &Editor| {
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
(
|
||||
picker.query.clone(),
|
||||
picker.selected,
|
||||
picker.entries().len(),
|
||||
)
|
||||
} else {
|
||||
let palette = editor.palette().unwrap();
|
||||
(
|
||||
palette.query.clone(),
|
||||
palette.selected,
|
||||
palette.entries.len(),
|
||||
)
|
||||
}
|
||||
};
|
||||
let (query, _, len) = list(&editor);
|
||||
assert!(len > 1);
|
||||
let snapshot =
|
||||
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
|
||||
let text = editor.context().text;
|
||||
let targets = editor.active.targets.clone();
|
||||
let primary = editor.active.primary;
|
||||
let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect();
|
||||
let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect();
|
||||
let insert = editor.active.insert_start.as_ref().map(snapshot);
|
||||
editor.count = Some(7);
|
||||
for (input, selected) in [
|
||||
(Input::Ctrl('K'), len - 1),
|
||||
(Input::Ctrl('J'), 0),
|
||||
(Input::Ctrl('j'), 1),
|
||||
(Input::Ctrl('k'), 0),
|
||||
(Input::Up, len - 1),
|
||||
(Input::Down, 0),
|
||||
(Input::Ctrl('n'), 1),
|
||||
] {
|
||||
assert_eq!(editor.handle(input), None);
|
||||
assert_eq!(list(&editor), (query.clone(), selected, len));
|
||||
}
|
||||
assert_eq!(editor.mode(), mode);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.active.targets, targets);
|
||||
assert_eq!(editor.active.primary, primary);
|
||||
assert_eq!(editor.command_line, "original prompt");
|
||||
assert_eq!(editor.count(), Some(7));
|
||||
assert_eq!(
|
||||
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||||
undo
|
||||
);
|
||||
assert_eq!(
|
||||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||||
redo
|
||||
);
|
||||
assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert);
|
||||
type_text(&mut editor, "missing");
|
||||
assert_eq!(list(&editor).2, 0);
|
||||
for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] {
|
||||
assert_eq!(editor.handle(input), None);
|
||||
assert_eq!(list(&editor).1, 0);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.mode(), mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a.txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "file text").unwrap();
|
||||
}
|
||||
for surface in [
|
||||
None,
|
||||
Some(FilePresentation::Floating),
|
||||
Some(FilePresentation::Docked),
|
||||
] {
|
||||
let mut editor = project_editor(&directory);
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous"
|
||||
);
|
||||
type_text(&mut editor, "3 b");
|
||||
let pending = editor.leader_pending().unwrap().to_owned();
|
||||
editor
|
||||
.bind("selection", "x", Some("core.down".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("selection", "z", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.bind("selection", "ctrl+j", None).unwrap();
|
||||
editor.bind("selection", "!", None).unwrap();
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
assert_eq!(editor.leader_pending(), Some(pending.as_str()));
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"Ctrl+n/Down/x next · Ctrl+k/Up/z previous"
|
||||
);
|
||||
let hint = editor.selection_navigation_hint().to_owned();
|
||||
for (mode, key, target) in [
|
||||
("selection", "x", Some("core.delete")),
|
||||
("selection", "x", Some("core.palette")),
|
||||
("selection", "x", Some("w")),
|
||||
("selection", "leader x", Some("core.down")),
|
||||
("selection", "LEADER x", None),
|
||||
("selection", "x z", Some("core.up")),
|
||||
("selection", "alt+x", Some("core.up")),
|
||||
("invalid", "x", Some("core.up")),
|
||||
] {
|
||||
assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err());
|
||||
assert_eq!(editor.selection_navigation_hint(), hint);
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
assert_eq!(editor.leader_pending(), Some(pending.as_str()));
|
||||
}
|
||||
editor.cancel_leader();
|
||||
// Editing bindings cannot disable or remap list bindings.
|
||||
editor
|
||||
.bind("normal", "x", Some("core.insert".into()))
|
||||
.unwrap();
|
||||
editor.bind("normal", "ctrl+k", None).unwrap();
|
||||
if let Some(presentation) = surface {
|
||||
editor.open_file_picker(presentation);
|
||||
} else {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
}
|
||||
let selected = |editor: &Editor| {
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
picker.selected
|
||||
} else {
|
||||
editor.palette().unwrap().selected
|
||||
}
|
||||
};
|
||||
for (input, expected) in [
|
||||
(Input::Char('x'), 1),
|
||||
(Input::Ctrl('j'), 1),
|
||||
(Input::Char('!'), 1),
|
||||
(Input::Char('z'), 0),
|
||||
(Input::Char('x'), 1),
|
||||
(Input::Ctrl('k'), 0),
|
||||
] {
|
||||
editor.handle(input);
|
||||
assert_eq!(selected(&editor), expected);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
// Updating the map does not rebuild/reset the selected list.
|
||||
editor.handle(Input::Char('x'));
|
||||
editor
|
||||
.bind("selection", "ctrl+p", Some("core.down".into()))
|
||||
.unwrap();
|
||||
assert_eq!(selected(&editor), 1);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert_eq!(selected(&editor), 2);
|
||||
editor.bind("selection", "backspace", None).unwrap();
|
||||
type_text(&mut editor, "a");
|
||||
editor.handle(Input::Backspace);
|
||||
let query = if let Some(picker) = editor.file_picker() {
|
||||
&picker.query
|
||||
} else {
|
||||
&editor.palette().unwrap().query
|
||||
};
|
||||
assert_eq!(query, "a");
|
||||
for key in ["down", "ctrl+n", "x", "ctrl+p"] {
|
||||
editor.bind("selection", key, None).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
editor.selection_navigation_hint(),
|
||||
"unbound next · Ctrl+k/Up/z previous"
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('x'));
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() {
|
||||
assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j'));
|
||||
for (key, input) in [
|
||||
("ctrl+m", Input::Enter),
|
||||
("ctrl+h", Input::Backspace),
|
||||
("ctrl+i", Input::Char('\t')),
|
||||
] {
|
||||
assert_eq!(parse_key(key).unwrap(), input);
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.bind("normal", "ctrl+j", Some("core.insert".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
editor.bind("insert", "enter", None).unwrap();
|
||||
type_text(&mut editor, "one");
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
type_text(&mut editor, "two");
|
||||
assert_eq!(editor.context().text, "one\ntwo");
|
||||
editor.handle(Input::Escape);
|
||||
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||
type_text(&mut editor, ":greet arguments");
|
||||
let invocation = editor.handle(Input::Ctrl('j')).unwrap();
|
||||
assert_eq!(invocation.command, "greet");
|
||||
assert_eq!(invocation.args, "arguments");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
type_text(&mut editor, "/two");
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.active.search_query, "two");
|
||||
assert_eq!(editor.cursor().row, 1);
|
||||
editor
|
||||
.bind("visual", "ctrl+j", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_j_navigates_lists_while_enter_accepts() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("a.txt"), "first").unwrap();
|
||||
fs::write(directory.path("b.txt"), "second").unwrap();
|
||||
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.open_file_picker(presentation);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 1);
|
||||
assert_eq!(editor.context().text, "");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.context().text, "second");
|
||||
assert_eq!(editor.path(), Some(directory.path("b.txt").as_path()));
|
||||
}
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.register(plugin("fixture", &["greet_one", "greet_two"]))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "greet");
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
assert_eq!(editor.palette().unwrap().selected, 1);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.command_line, "");
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line, "greet_two ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_actions_default_leaders_palette_and_side_configuration() {
|
||||
let directory = Directory::new();
|
||||
@@ -3239,6 +3590,8 @@ mod tests {
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 0);
|
||||
@@ -4595,6 +4948,8 @@ mod tests {
|
||||
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.palette().is_some());
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
type_text(&mut editor, "query Δe\u{301}👩🚀 ");
|
||||
editor.handle(Input::Backspace);
|
||||
editor.handle(Input::Backspace);
|
||||
@@ -4676,6 +5031,7 @@ mod tests {
|
||||
Input::Enter,
|
||||
Input::Up,
|
||||
Input::Down,
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Ctrl('n'),
|
||||
] {
|
||||
@@ -4720,6 +5076,9 @@ mod tests {
|
||||
assert!(!entry.enabled);
|
||||
assert_eq!(entry.modes, vec![Mode::Normal]);
|
||||
assert!(entry.bindings.is_empty());
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
assert_eq!(editor.palette().unwrap().query, "core.undo");
|
||||
assert_eq!(editor.handle(Input::Enter), None);
|
||||
assert!(editor.palette().is_some());
|
||||
assert_eq!(editor.mode(), mode);
|
||||
@@ -4755,7 +5114,16 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
palette_entry(&editor, "core.insert").bindings,
|
||||
vec!["Backspace", "Ctrl+r", "Enter", "I", "Space", "Tab", "界"]
|
||||
vec![
|
||||
"Backspace",
|
||||
"Ctrl+j",
|
||||
"Ctrl+r",
|
||||
"Enter",
|
||||
"I",
|
||||
"Space",
|
||||
"Tab",
|
||||
"界"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
palette_entry(&editor, "core.insert_first_nonblank")
|
||||
|
||||
+2
-2
@@ -7,11 +7,11 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
|
||||
Default keys (configurable with config.bind):
|
||||
All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept, Esc cancel
|
||||
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
|
||||
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
|
||||
Files: Space o floating picker; Space e docked explorer (right by default)
|
||||
type to filter, Up/Down select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes
|
||||
type to filter, Ctrl+j/k or arrows select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ module Vamp
|
||||
end
|
||||
|
||||
module Modes
|
||||
%w[normal insert command search visual].each do |name|
|
||||
%w[normal insert command search visual selection].each do |name|
|
||||
mode = Mode.new(name.freeze).freeze
|
||||
define_singleton_method(name) { mode }
|
||||
end
|
||||
|
||||
@@ -164,6 +164,53 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "one two three\none two three");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.bind "ctrl+j", nil, mode: config.modes.selection
|
||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+b", config.core.up, mode: config.modes.selection
|
||||
end
|
||||
"#,
|
||||
);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Ctrl('p'));
|
||||
for ch in "core.word".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Ctrl('j'));
|
||||
assert_eq!(session.editor().palette().unwrap().selected, 0);
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 1, column: 0 }
|
||||
);
|
||||
session.handle(Input::Ctrl('f'));
|
||||
assert_eq!(
|
||||
session.editor().palette().unwrap().entries[1].name,
|
||||
"core.word_next"
|
||||
);
|
||||
assert_eq!(session.editor().palette().unwrap().selected, 1);
|
||||
session.handle(Input::Ctrl('b'));
|
||||
assert_eq!(session.editor().palette().unwrap().selected, 0);
|
||||
session.handle(Input::Ctrl('f'));
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor().palette().is_none());
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 1, column: 4 }
|
||||
);
|
||||
session.handle(Input::Ctrl('k'));
|
||||
assert_eq!(session.editor().selection_count(), 2);
|
||||
assert_eq!(
|
||||
session.editor().lines(),
|
||||
&["one two three", "one two three"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_picker_presentations_preserve_editing_and_docked_focus() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+250
-33
@@ -156,7 +156,9 @@ fn translate(key: KeyEvent) -> Option<Input> {
|
||||
return None;
|
||||
}
|
||||
Some(match key.code {
|
||||
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c),
|
||||
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Input::Ctrl(c.to_ascii_lowercase())
|
||||
}
|
||||
KeyCode::Char(c) => Input::Char(c),
|
||||
KeyCode::Esc => Input::Escape,
|
||||
KeyCode::Enter => Input::Enter,
|
||||
@@ -425,13 +427,18 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel);
|
||||
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel);
|
||||
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
draw_palette(
|
||||
frame,
|
||||
palette,
|
||||
editor.mode(),
|
||||
editor.selection_navigation_hint(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +482,7 @@ fn display_text(text: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigation_hint: &str) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -582,12 +589,13 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
let help = if picker.focused {
|
||||
match picker.presentation {
|
||||
FilePresentation::Floating => "↑↓ Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "↑↓ Enter · Esc/Tab edit · ^W · ^R",
|
||||
}
|
||||
let controls = match picker.presentation {
|
||||
FilePresentation::Floating => "Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R",
|
||||
};
|
||||
format!("{navigation_hint} · {controls}")
|
||||
} else {
|
||||
"Editor focus · invoke explorer to search"
|
||||
"Editor focus · invoke explorer to search".into()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
|
||||
@@ -595,7 +603,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hint: &str) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
@@ -685,7 +693,7 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{mode} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
|
||||
"{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel"
|
||||
)),
|
||||
help_area,
|
||||
);
|
||||
@@ -738,6 +746,106 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
|
||||
for (code, modifiers, expected) in [
|
||||
(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::CONTROL,
|
||||
Some(Input::Ctrl('j')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('J'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
Some(Input::Ctrl('j')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('k'),
|
||||
KeyModifiers::CONTROL,
|
||||
Some(Input::Ctrl('k')),
|
||||
),
|
||||
(KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)),
|
||||
(KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)),
|
||||
(
|
||||
KeyCode::Char('J'),
|
||||
KeyModifiers::SHIFT,
|
||||
Some(Input::Char('J')),
|
||||
),
|
||||
(KeyCode::Char('j'), KeyModifiers::ALT, None),
|
||||
] {
|
||||
assert_eq!(translate(KeyEvent::new(code, modifiers)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_window_follows_control_j_k_selection() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
let count = editor.file_picker().unwrap().entries().len();
|
||||
assert!(count > 1);
|
||||
for _ in 0..count - 1 {
|
||||
editor.handle(
|
||||
translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap(),
|
||||
);
|
||||
}
|
||||
assert_eq!(editor.file_picker().unwrap().selected, count - 1);
|
||||
editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap());
|
||||
assert_eq!(editor.file_picker().unwrap().selected, count - 2);
|
||||
let picker = editor.file_picker().unwrap();
|
||||
let label = picker.entries().nth(picker.selected).unwrap().label.clone();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
frame.area(),
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&format!("> {label}")), "{text}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
|
||||
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_footers_show_remapped_and_disabled_selection_keys() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
|
||||
editor.bind("selection", key, None).unwrap();
|
||||
}
|
||||
editor
|
||||
.bind("selection", "x", Some("core.down".into()))
|
||||
.unwrap();
|
||||
let hint = editor.selection_navigation_hint().to_owned();
|
||||
assert_eq!(hint, "x next · unbound previous");
|
||||
editor.set_file_explorer_side("bottom").unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for key in ['o', 'e'] {
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(key));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&hint), "{text}");
|
||||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
|
||||
let directory = Directory::new();
|
||||
@@ -745,6 +853,7 @@ mod tests {
|
||||
directory.file(&format!("file-{index:02}"), "text");
|
||||
}
|
||||
let special = directory.file("界e\u{301} space\nname", "text");
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
|
||||
assert!(
|
||||
picker
|
||||
@@ -765,7 +874,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("file-00"), "{text}");
|
||||
@@ -792,11 +901,12 @@ mod tests {
|
||||
#[test]
|
||||
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
|
||||
let directory = Directory::new();
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
let area = Rect::new(0, 0, 100, 12);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Explorer"), "{text}");
|
||||
@@ -812,7 +922,7 @@ mod tests {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.set_cursor_position((99, 11));
|
||||
draw_file_list(frame, &picker, area);
|
||||
draw_file_list(frame, &picker, area, editor.selection_navigation_hint());
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
@@ -828,7 +938,7 @@ mod tests {
|
||||
picker.presentation = FilePresentation::Floating;
|
||||
picker.focused = true;
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint()))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||
@@ -838,6 +948,7 @@ mod tests {
|
||||
#[test]
|
||||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||||
let directory = Directory::new();
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
picker.query = "long界e\u{301}z".into();
|
||||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||||
@@ -850,7 +961,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.draw(|frame| {
|
||||
draw_file_list(frame, &picker, area, editor.selection_navigation_hint())
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
@@ -899,7 +1012,14 @@ mod tests {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, frame.area()))
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||||
@@ -1673,7 +1793,7 @@ mod tests {
|
||||
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('j'));
|
||||
}
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
@@ -1746,6 +1866,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
let palette = Palette {
|
||||
query: "long\t界e\u{301}z".into(),
|
||||
entries: vec![],
|
||||
@@ -1761,7 +1882,14 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Visual))
|
||||
.draw(|frame| {
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
Mode::Visual,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
@@ -1795,7 +1923,14 @@ mod tests {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Normal))
|
||||
.draw(|frame| {
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
Mode::Normal,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2080,29 +2215,75 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn terminal_session_scopes_the_callers_panic_hook() {
|
||||
use std::io::Write;
|
||||
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
|
||||
if let Ok(mode) = std::env::var(CHILD) {
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
println!("CALLER_HOOK");
|
||||
eprintln!("{info}");
|
||||
}));
|
||||
if mode == "entry" {
|
||||
let mut editor = EditorSession::open(None, vec![], None).unwrap();
|
||||
assert!(run(&mut editor).is_err());
|
||||
} else {
|
||||
let result = panic::catch_unwind(|| {
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
TerminalSession::run(|| {
|
||||
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
match mode.as_str() {
|
||||
"normal" => Ok(()),
|
||||
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||
"panic" => panic::panic_any(73_u64),
|
||||
"keys" => {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
let original = editor.lines().to_vec();
|
||||
println!("READING_KEYS");
|
||||
io::stdout().flush()?;
|
||||
for (index, expected) in [
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Ctrl('j'),
|
||||
Input::Ctrl('k'),
|
||||
Input::Enter,
|
||||
Input::Enter,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Event::Key(key) = event::read()? else {
|
||||
panic!("expected a keyboard event");
|
||||
};
|
||||
let input = translate(key).unwrap();
|
||||
assert_eq!(input, expected);
|
||||
if index == 4 {
|
||||
for ch in "q!".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
editor.handle(input);
|
||||
match index {
|
||||
0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1),
|
||||
1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0),
|
||||
4 => {
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line(), "q! ");
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
5 => assert!(editor.should_quit()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(editor.lines(), original);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
})
|
||||
});
|
||||
}));
|
||||
match mode.as_str() {
|
||||
"normal" => result.unwrap().unwrap(),
|
||||
"normal" | "keys" => result.unwrap().unwrap(),
|
||||
"error" => assert_eq!(
|
||||
result.unwrap().unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
@@ -2144,32 +2325,68 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
let check = |output: std::process::Output| {
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let check = |output: std::process::Output, mode: &str| {
|
||||
assert!(output.status.success(), "{mode}: {output:?}");
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let after = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap().1;
|
||||
let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap();
|
||||
let entered = usize::from(mode != "entry");
|
||||
assert_eq!(
|
||||
before.matches("\x1b[?1049h").count(),
|
||||
entered,
|
||||
"{mode}: {before:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
before.matches("\x1b[?1049l").count(),
|
||||
entered,
|
||||
"{mode}: {before:?}"
|
||||
);
|
||||
if mode == "panic" {
|
||||
assert!(
|
||||
before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(),
|
||||
"{before:?}",
|
||||
);
|
||||
}
|
||||
assert!(after.contains("CALLER_HOOK"), "{after:?}");
|
||||
assert!(
|
||||
!after.contains('\u{1b}'),
|
||||
"terminal hook outlived its session: {after:?}"
|
||||
);
|
||||
};
|
||||
check(command.output().unwrap());
|
||||
check(command.output().unwrap(), "entry");
|
||||
|
||||
// Ruby's standard-library PTY gives each positive-entry scenario a real
|
||||
// terminal, keeping process-global hooks isolated from the test runner.
|
||||
for mode in ["normal", "error", "panic"] {
|
||||
for mode in ["normal", "error", "panic", "keys"] {
|
||||
let output =
|
||||
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
||||
.args([
|
||||
"-rpty",
|
||||
"-rtimeout",
|
||||
"-e",
|
||||
r#"
|
||||
PTY.spawn(*ARGV) do |output, input, pid|
|
||||
input.close
|
||||
sent_keys = false
|
||||
captured = +""
|
||||
begin
|
||||
loop { STDOUT.write(output.readpartial(4096)) }
|
||||
Timeout.timeout(10) do
|
||||
loop do
|
||||
chunk = output.readpartial(4096)
|
||||
STDOUT.write(chunk)
|
||||
captured << chunk
|
||||
if !sent_keys && captured.include?("READING_KEYS")
|
||||
input.write("\e[106;5u\e[107;5u\n\v\e[13u\r")
|
||||
input.flush
|
||||
sent_keys = true
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue EOFError, Errno::EIO
|
||||
rescue Timeout::Error
|
||||
Process.kill("TERM", pid)
|
||||
Process.wait(pid)
|
||||
abort "terminal lifecycle probe timed out"
|
||||
ensure
|
||||
input.close
|
||||
end
|
||||
Process.wait(pid)
|
||||
exit($?.exitstatus || 1)
|
||||
@@ -2188,7 +2405,7 @@ end
|
||||
.env(CHILD, mode)
|
||||
.output()
|
||||
.unwrap();
|
||||
check(output);
|
||||
check(output, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user