diff --git a/README.md b/README.md index e2f5b49..d66bf80 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/examples/config.rb b/examples/config.rb index 9a202ef..ac1423c 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -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" diff --git a/src/editor.rs b/src/editor.rs index 13f33d4..4b1567c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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> { Submit }, ), + ( + Input::Ctrl('j'), + if mode == Mode::Insert { + Newline + } else { + Submit + }, + ), ] { bindings .entry(mode) @@ -698,6 +706,8 @@ pub struct Editor { capabilities: Vec, features: HashMap, bindings: HashMap>, + selection_bindings: HashMap, + selection_navigation_hint: String, leader: Input, leader_bindings: HashMap, Binding>>, leader_suffix: Vec, @@ -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) -> 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, command: &str) -> Vec { // 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 { - 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::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + 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") diff --git a/src/main.rs b/src/main.rs index 2446ab9..2b436e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 diff --git a/src/ruby_host.rb b/src/ruby_host.rb index abe2aae..01172cd 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -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 diff --git a/src/session.rs b/src/session.rs index 6108955..401e288 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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(); diff --git a/src/tui.rs b/src/tui.rs index ef0c418..6232eea 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -156,7 +156,9 @@ fn translate(key: KeyEvent) -> Option { 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); } } }