From 3359f038dc52a3eba6ac9388ccd23a5c1798319c Mon Sep 17 00:00:00 2001 From: Lauri Gates Date: Tue, 23 Jun 2026 21:39:50 +0300 Subject: [PATCH] feat(editor): add opt-in vi-style modal editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements vi-style editing for the query editor (issue #80). Disabled by default — set `enable = true` under `[editor.vi]` to turn it on — so existing emacs-style keybindings are unchanged for everyone else. The modal logic lives in a self-contained `vi` module as a pure transformation over `(text, cursor)`, decoupled from the promkit text editor and fully unit tested. The query editor applies the engine's outcomes via the widget's existing primitives. NORMAL mode routes through the engine; INSERT mode falls through to the original text-editing path, so typing, completion, and the configured keybinds keep working unchanged. A configurable NORMAL-mode prefix makes the current mode visible. Supported in NORMAL mode: - Motions: h l 0 $ ^ w W b B e E f F t T gg G - Operators: d c y with those motions, plus linewise dd cc yy - Edits: x X D C s S r ~ p P - Counts (e.g. 3w, d2w) and undo (u) / redo (Ctrl+R) The query buffer is single-line, so linewise commands act on the whole line and o/O/./multi-line motions are intentionally unsupported. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 + default.toml | 15 + src/config.rs | 96 ++++ src/main.rs | 2 + src/query_editor.rs | 154 ++++++- src/vi.rs | 1077 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1363 insertions(+), 3 deletions(-) create mode 100644 src/vi.rs diff --git a/README.md b/README.md index e1edbf1..fbfa7f8 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,28 @@ cat data.json | jnv -- --write-to-stdout > result.json | | Select previous suggestion | | Others | Return to editor | +### Editor mode (vi-style) + +Optional modal editing for the query editor, disabled by default. Enable it by +setting `enable = true` under `[editor.vi]` in the configuration file. The +editor then starts in NORMAL mode; the prefix (`❮❮ ` by default) indicates the +mode. Press i/a/I/A to enter INSERT +mode — where typing, completion, and the keybindings above all work as usual — +and Esc to return to NORMAL mode. + +The query buffer is single-line, so linewise commands (`dd`, `cc`, `yy`) act on +the whole line, and `o`/`O`/`.`/multi-line motions are not supported. + +| Category | Keys | +| :- | :- | +| Motions | `h` `l` `0` `$` `^` `w` `W` `b` `B` `e` `E` `f{char}` `F{char}` `t{char}` `T{char}` `gg` `G` | +| Insert | `i` `I` `a` `A` | +| Operators | `d`/`c`/`y` + motion, and linewise `dd` `cc` `yy` | +| Edits | `x` `X` `D` `C` `s` `S` `r{char}` `~` | +| Paste | `p` `P` (from the last delete/yank) | +| Counts | prefix a motion or operator, e.g. `3w`, `d2w`, `5x` | +| Undo / redo | `u` / Ctrl + R | + ### JSON viewer mode | Key | Action | diff --git a/default.toml b/default.toml index 05f892e..702f1a5 100644 --- a/default.toml +++ b/default.toml @@ -57,6 +57,21 @@ active_char_style = "attr=dim" # Style for all other characters when unfocused inactive_char_style = "attr=dim" +# vi-style modal editing for the query editor +[editor.vi] +# Enable vi-style modal editing. +# When true, the editor starts in NORMAL mode and interprets keys as vi +# motions, operators, and edit commands. Press i/a/I/A to enter INSERT mode +# (where typing, completion, and the keybinds above all work as usual), and +# Esc to return to NORMAL mode. +# Supported in NORMAL mode: motions h l 0 $ ^ w W b B e E f F t T gg G; +# operators d c y with those motions plus the linewise dd cc yy; edits +# x X D C s S r ~ p P; counts (e.g. 3w, d2w); and undo u / redo Ctrl+R. +# The buffer is single-line, so linewise commands act on the whole line. +enable = false +# Prefix shown while in NORMAL mode (INSERT mode shows on_focus.prefix above). +normal_prefix = "❮❮ " + # JSON display settings [json] # Maximum number of JSON objects to read from streams (e.g., JSON Lines format) diff --git a/src/config.rs b/src/config.rs index fb8ee63..dfdb5ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,32 @@ use duration::duration_serde; pub struct EditorConfig { pub on_focus: text_editor::Config, pub on_defocus: text_editor::Config, + /// vi-style modal editing settings. Optional so existing configuration + /// files written before this feature continue to load. + #[serde(default)] + pub vi: ViConfig, +} + +/// Settings for vi-style modal editing in the query editor. +#[derive(Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ViConfig { + /// When `true`, the query editor starts in NORMAL mode and interprets keys + /// as vi motions, operators, and edit commands. Press `i`/`a` to insert and + /// Esc to return to NORMAL mode. + pub enable: bool, + /// Prefix shown while in NORMAL mode, so the current mode is visible. The + /// configured `on_focus.prefix` is shown while in INSERT mode. + pub normal_prefix: String, +} + +impl Default for ViConfig { + fn default() -> Self { + Self { + enable: false, + normal_prefix: "❮❮ ".to_string(), + } + } } #[derive(Serialize, Deserialize)] @@ -140,3 +166,73 @@ impl Config { toml::from_str(content).map_err(Into::into) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_parses() { + let config = Config::load_from(DEFAULT_CONFIG).expect("default config must parse"); + // vi editing ships disabled so existing keybindings are unchanged. + assert!(!config.editor.vi.enable); + assert_eq!(config.editor.vi.normal_prefix, "❮❮ "); + } + + #[test] + fn vi_section_is_optional_for_backward_compatibility() { + // A config written before the vi feature has no `[editor.vi]` table; it + // must still load, falling back to the vi defaults. + let without_vi = r#" +no_hint = false + +[reactivity_control] +query_debounce_duration = "600ms" +resize_debounce_duration = "200ms" +spin_duration = "300ms" + +[editor.on_focus] +[editor.on_defocus] + +[json] +[json.stream] + +[completion] +search_result_chunk_size = 100 +search_load_chunk_size = 50000 +[completion.listbox] + +[keybinds] +exit = ["Ctrl+C"] +copy_query = ["Ctrl+Q"] +copy_result = ["Ctrl+O"] +switch_mode = ["Shift+Down"] + +[keybinds.on_editor] +backward = ["Left"] +forward = ["Right"] +move_to_head = ["Ctrl+A"] +move_to_tail = ["Ctrl+E"] +move_to_previous_nearest = ["Alt+B"] +move_to_next_nearest = ["Alt+F"] +erase = ["Backspace"] +erase_all = ["Ctrl+U"] +erase_to_previous_nearest = ["Ctrl+W"] +erase_to_next_nearest = ["Alt+D"] +completion = ["Tab"] +on_completion.up = ["Up"] +on_completion.down = ["Down"] + +[keybinds.on_json_viewer] +up = ["Up"] +down = ["Down"] +move_to_head = ["Ctrl+L"] +move_to_tail = ["Ctrl+H"] +toggle = ["Enter"] +expand = ["Ctrl+P"] +collapse = ["Ctrl+N"] +"#; + let config = Config::load_from(without_vi).expect("config without [editor.vi] must parse"); + assert!(!config.editor.vi.enable); + } +} diff --git a/src/main.rs b/src/main.rs index 15807d7..fe2006a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ mod runtime_tasks; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod utils; +mod vi; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -226,6 +227,7 @@ async fn main() -> anyhow::Result<()> { config.editor.on_defocus, // TODO: remove clones config.keybinds.on_editor.clone(), + config.editor.vi, ); // Redirects stdout to prevent interference with TUI interface. diff --git a/src/query_editor.rs b/src/query_editor.rs index 54dac54..f0cfcf5 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -15,9 +15,10 @@ use tokio::{ use crate::{ completion::CompletionAction, - config::EditorKeybinds, + config::{EditorKeybinds, ViConfig}, context::{Index, SharedContext}, guide::{self, GuideAction}, + vi, }; /// Editor for inputting jq query. It manages the state of the text editor @@ -27,6 +28,15 @@ pub struct QueryEditor { focus_config: text_editor::Config, defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, + /// vi modal state, present only when vi-style editing is enabled. + vi: Option, + /// Prefix shown in NORMAL mode (vi only). + vi_normal_prefix: String, + /// Prefix shown in INSERT mode (the configured focus prefix). + insert_prefix: String, + /// Undo / redo stacks of `(text, cursor)` snapshots (vi only). + undo_stack: Vec<(String, usize)>, + redo_stack: Vec<(String, usize)>, } impl QueryEditor { @@ -35,18 +45,34 @@ impl QueryEditor { focus_config: text_editor::Config, defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, + vi_config: ViConfig, ) -> Self { - Self { + let insert_prefix = focus_config.prefix.clone(); + let mut editor = Self { state, focus_config, defocus_config, editor_keybinds, + vi: vi_config.enable.then(|| vi::Editor::new(vi::Mode::Normal)), + vi_normal_prefix: vi_config.normal_prefix, + insert_prefix, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + }; + if editor.vi.is_some() { + // NORMAL-mode cursor rests on a char, not the trailing append slot. + let len = editor.text().chars().count(); + let cursor = editor.cursor().min(len.saturating_sub(1)); + editor.set_cursor(cursor); + editor.refresh_prefix(); } + editor } /// Focus the query editor, applying the focus configuration. pub fn focus(&mut self) { self.state.config = self.focus_config.clone(); + self.refresh_prefix(); } /// Defocus the query editor, applying the defocus configuration. @@ -69,6 +95,57 @@ impl QueryEditor { self.state.texteditor.replace(text); } + /// The current cursor position as a `char` index. + fn cursor(&self) -> usize { + self.state.texteditor.position() + } + + /// Move the cursor to an absolute `char` index. + fn set_cursor(&mut self, pos: usize) { + self.state.texteditor.move_to_head(); + for _ in 0..pos { + if !self.state.texteditor.forward() { + break; + } + } + } + + /// Replace the buffer and place the cursor at an absolute `char` index. + fn set_buffer(&mut self, text: &str, cursor: usize) { + self.state.texteditor.replace(text); + self.set_cursor(cursor); + } + + /// Apply the vi NORMAL-mode prefix or the INSERT-mode (focus) prefix. + fn refresh_prefix(&mut self) { + let Some(mode) = self.vi.as_ref().map(|v| v.mode) else { + return; + }; + self.state.config.prefix = match mode { + vi::Mode::Normal => self.vi_normal_prefix.clone(), + vi::Mode::Insert => self.insert_prefix.clone(), + }; + } + + fn push_undo(&mut self, text: String, cursor: usize) { + self.undo_stack.push((text, cursor)); + self.redo_stack.clear(); + } + + fn undo(&mut self) { + if let Some((text, cursor)) = self.undo_stack.pop() { + self.redo_stack.push((self.text(), self.cursor())); + self.set_buffer(&text, cursor); + } + } + + fn redo(&mut self) { + if let Some((text, cursor)) = self.redo_stack.pop() { + self.undo_stack.push((self.text(), self.cursor())); + self.set_buffer(&text, cursor); + } + } + /// Handle a user input event to update the query editor's state accordingly. /// Returns `true` if the event triggers the completion action, otherwise `false`. fn handle_user_event(&mut self, event: &Event) -> bool { @@ -76,6 +153,78 @@ impl QueryEditor { return true; } + if self.vi.is_some() { + self.handle_vi_event(event); + } else { + self.handle_plain_event(event); + } + false + } + + /// Route an event through the vi state machine. In INSERT mode all keys + /// except Esc fall through to the normal text-editing path, so + /// character insertion and the configured keybinds keep working. + fn handle_vi_event(&mut self, event: &Event) { + let Event::Key(key) = event else { + return; + }; + if key.kind != KeyEventKind::Press { + return; + } + + let mode = self.vi.as_ref().map(|v| v.mode).unwrap_or(vi::Mode::Insert); + match mode { + vi::Mode::Insert => { + if key.code == KeyCode::Esc { + let cursor = self.cursor(); + let new_cursor = self.vi.as_mut().expect("vi enabled").leave_insert(cursor); + self.set_cursor(new_cursor); + self.refresh_prefix(); + } else { + self.handle_plain_event(event); + } + } + vi::Mode::Normal => { + let pending = self.vi.as_ref().expect("vi enabled").is_pending(); + // `u` / Ctrl+R are undo/redo, but only when not mid-command + // (so `fu`, `ru`, etc. still see `u` as their argument). + if !pending { + if key.code == KeyCode::Char('u') && key.modifiers == KeyModifiers::NONE { + self.undo(); + return; + } + if key.code == KeyCode::Char('r') && key.modifiers == KeyModifiers::CONTROL { + self.redo(); + return; + } + } + + let text = self.text(); + let cursor = self.cursor(); + let outcome = self + .vi + .as_mut() + .expect("vi enabled") + .handle_normal(key, &text, cursor); + match outcome { + vi::Outcome::Move(pos) => self.set_cursor(pos), + vi::Outcome::Replace { + text: new_text, + cursor: new_cursor, + } => { + self.push_undo(text, cursor); + self.set_buffer(&new_text, new_cursor); + } + vi::Outcome::Noop => {} + } + self.refresh_prefix(); + } + } + } + + /// Handle an event as plain (non-modal) text editing — the original editor + /// behavior, also used for vi INSERT mode. + fn handle_plain_event(&mut self, event: &Event) { match event { key if self.editor_keybinds.backward.contains(key) => { self.state.texteditor.backward(); @@ -132,7 +281,6 @@ impl QueryEditor { }, _ => {} } - false } } diff --git a/src/vi.rs b/src/vi.rs new file mode 100644 index 0000000..2401ee6 --- /dev/null +++ b/src/vi.rs @@ -0,0 +1,1077 @@ +//! A small, self-contained vi-style modal editing engine for the query editor. +//! +//! The engine is intentionally decoupled from the promkit text editor: every +//! operation is expressed as a pure transformation over the current query text +//! (a `&str`) and a cursor index (a `char` offset), returning an [`Outcome`] +//! that the caller applies to the underlying widget. This keeps the modal logic +//! free of widget/rendering concerns and trivially unit-testable. +//! +//! Only NORMAL-mode keys flow through [`Editor::handle_normal`]; INSERT mode is +//! handled by the caller's existing text-insertion path so that completion and +//! the configured emacs-style keybinds keep working unchanged. The single +//! INSERT-mode concern the engine owns is the transition back to NORMAL on +//! Esc, exposed via [`Editor::leave_insert`]. +//! +//! The buffer is single-line (the query editor has no newlines), so linewise +//! operators (`dd`, `cc`, `yy`) act on the whole line and `o`/`O`/`.`/multi-line +//! motions are intentionally unsupported. + +use promkit_widgets::core::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// The current editing mode. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Mode { + /// Keys are interpreted as motions, operators, and edit commands. + #[default] + Normal, + /// Keys insert text, exactly like the non-vi editor. + Insert, +} + +/// The effect a NORMAL-mode key has on the editor buffer. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum Outcome { + /// Move the cursor to the given absolute `char` index. The text is + /// unchanged. Also used for pure mode changes (`i`, `a`, ...), where the + /// new [`Mode`] is read from [`Editor::mode`] afterwards. + Move(usize), + /// Replace the whole buffer with `text` and place the cursor at `cursor`. + Replace { text: String, cursor: usize }, + /// The key was consumed but nothing should change (a partial command such + /// as a pending count/operator, or an unhandled key). + Noop, +} + +/// Pending operator (`d`, `c`, `y`) awaiting a motion, with its own count. +#[derive(Clone, Copy)] +struct Pending { + op: Operator, + count: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Operator { + Delete, + Change, + Yank, +} + +/// A key that consumes the following keypress as its argument. +#[derive(Clone, Copy)] +enum AwaitChar { + /// `f`/`F`/`t`/`T` (optionally operator-pending). + Find { forward: bool, till: bool }, + /// `r` — replace the char(s) under the cursor. + Replace, +} + +/// A resolved motion target plus whether it is inclusive of the target char. +struct Motion { + pos: usize, + inclusive: bool, +} + +/// The modal editing state machine. +pub struct Editor { + pub mode: Mode, + /// Numeric count prefix being accumulated (e.g. the `3` in `3w`). + count: Option, + /// Operator awaiting a motion. + pending: Option, + /// A key awaiting its character argument. + await_char: Option, + /// Whether the previous key was `g` (awaiting a second `g` for `gg`). + pending_g: bool, + /// The unnamed register, filled by yanks and deletes, pasted by `p`/`P`. + register: String, +} + +impl Default for Editor { + fn default() -> Self { + Self { + mode: Mode::Normal, + count: None, + pending: None, + await_char: None, + pending_g: false, + register: String::new(), + } + } +} + +impl Editor { + pub fn new(mode: Mode) -> Self { + Self { + mode, + ..Default::default() + } + } + + /// Leave INSERT mode and return to NORMAL, mirroring vi's "step left on + /// Esc" behaviour. `cursor` is the current cursor index; the returned index + /// is where the cursor should land. + pub fn leave_insert(&mut self, cursor: usize) -> usize { + self.mode = Mode::Normal; + self.reset_pending(); + cursor.saturating_sub(1) + } + + /// Whether the engine is mid-command (operator/count/await pending). Used by + /// the caller to decide whether a key is part of a vi command. + pub fn is_pending(&self) -> bool { + self.pending.is_some() + || self.count.is_some() + || self.await_char.is_some() + || self.pending_g + } + + fn reset_pending(&mut self) { + self.count = None; + self.pending = None; + self.await_char = None; + self.pending_g = false; + } + + /// Handle a key while in NORMAL mode. + /// + /// `text` is the query text without the cursor, `cursor` is the cursor's + /// `char` index (`0..=text.chars().count()`). + pub fn handle_normal(&mut self, key: &KeyEvent, text: &str, cursor: usize) -> Outcome { + let chars: Vec = text.chars().collect(); + let len = chars.len(); + let cursor = cursor.min(len); + + // Resolve a key that was awaiting a character argument (f/F/t/T/r). + if let Some(awaiting) = self.await_char.take() { + return self.resolve_await(awaiting, key, &chars, cursor, len); + } + + let Some(ch) = vi_char(key) else { + // Non-character keys: map the common navigation keys, ignore the + // rest. Any of these also cancels a pending command. + let outcome = match key.code { + KeyCode::Left | KeyCode::Backspace => Outcome::Move(cursor.saturating_sub(1)), + KeyCode::Right => Outcome::Move(clamp_normal(cursor + 1, len)), + KeyCode::Home => Outcome::Move(0), + KeyCode::End => Outcome::Move(clamp_normal(len, len)), + KeyCode::Esc => Outcome::Noop, + _ => Outcome::Noop, + }; + self.reset_pending(); + return outcome; + }; + + // `gg` — the only two-key prefix. + if self.pending_g { + self.pending_g = false; + if ch == 'g' { + return self.run_motion_or_operator(Motion::excl(0), &chars, cursor, len, None); + } + // Fall through: treat as a fresh key (vi ignores most `g{x}`). + } + + // Count accumulation: digits 1-9 always, 0 only to extend an existing + // count (a leading 0 is the "line start" motion). + if ch.is_ascii_digit() && !(ch == '0' && self.count.is_none()) { + let digit = ch as usize - '0' as usize; + self.count = Some(self.count.unwrap_or(0) * 10 + digit); + return Outcome::Noop; + } + + // Operators: d / c / y. A repeated operator (dd/cc/yy) is linewise. + if let Some(op) = operator_of(ch) { + if let Some(p) = self.pending { + if p.op == op { + // dd / cc / yy — whole line (counts are a no-op here). + return self.apply_linewise(op, &chars); + } + } + self.pending = Some(Pending { + op, + count: self.count.take(), + }); + return Outcome::Noop; + } + + // `g` prefix. + if ch == 'g' { + self.pending_g = true; + return Outcome::Noop; + } + + // Motions that can also be operator targets. + if let Some(motion) = self.resolve_motion(ch, &chars, cursor, len) { + let word_forward = if is_word_forward(ch) { + Some(ch == 'W') + } else { + None + }; + return self.run_motion_or_operator(motion, &chars, cursor, len, word_forward); + } + + // Keys that await a character argument. + match ch { + 'f' => { + self.await_char = Some(AwaitChar::Find { + forward: true, + till: false, + }); + return Outcome::Noop; + } + 'F' => { + self.await_char = Some(AwaitChar::Find { + forward: false, + till: false, + }); + return Outcome::Noop; + } + 't' => { + self.await_char = Some(AwaitChar::Find { + forward: true, + till: true, + }); + return Outcome::Noop; + } + 'T' => { + self.await_char = Some(AwaitChar::Find { + forward: false, + till: true, + }); + return Outcome::Noop; + } + 'r' => { + self.await_char = Some(AwaitChar::Replace); + return Outcome::Noop; + } + _ => {} + } + + // Mode-entry and standalone edit commands. These cancel any pending + // operator (none of them combine with one). + let count = mul_counts(self.pending.take().and_then(|p| p.count), self.count.take()); + self.run_command(ch, &chars, cursor, len, count) + } + + /// Resolve a key that follows `f`/`F`/`t`/`T`/`r`. + fn resolve_await( + &mut self, + awaiting: AwaitChar, + key: &KeyEvent, + chars: &[char], + cursor: usize, + len: usize, + ) -> Outcome { + let Some(target) = vi_char(key) else { + self.reset_pending(); + return Outcome::Noop; + }; + match awaiting { + AwaitChar::Find { forward, till } => { + let count = mul_counts(self.pending.and_then(|p| p.count), self.count.take()); + match find_char(chars, cursor, target, forward, till, count) { + Some(motion) => self.run_motion_or_operator(motion, chars, cursor, len, None), + None => { + self.reset_pending(); + Outcome::Noop + } + } + } + AwaitChar::Replace => { + let count = self.count.take().unwrap_or(1); + self.reset_pending(); + if cursor >= len || cursor + count > len { + return Outcome::Noop; + } + let mut out: Vec = chars.to_vec(); + for c in out.iter_mut().skip(cursor).take(count) { + *c = target; + } + Outcome::Replace { + text: out.into_iter().collect(), + cursor: cursor + count - 1, + } + } + } + } + + /// Apply a resolved motion, either as a cursor move (no operator) or as the + /// target of a pending operator. + /// + /// `word_forward` is `Some(big)` when the motion was `w`/`W` (so the + /// `cw`-as-`ce` quirk can be applied), or `None` otherwise. + fn run_motion_or_operator( + &mut self, + motion: Motion, + chars: &[char], + cursor: usize, + len: usize, + word_forward: Option, + ) -> Outcome { + if let Some(p) = self.pending.take() { + self.count = None; + // `cw` behaves like `ce` on a non-blank char (a vi quirk). + let motion = match word_forward { + Some(big) + if p.op == Operator::Change + && cursor < len + && !chars[cursor].is_whitespace() => + { + Motion::incl(next_word_end(chars, cursor, big)) + } + _ => motion, + }; + self.apply_operator(p.op, chars, cursor, motion, len) + } else { + self.count = None; + Outcome::Move(clamp_normal(motion.pos, len)) + } + } + + /// Resolve a single-key motion to a [`Motion`], or `None` if `ch` is not a + /// motion. `count` (from `self.count` / pending operator) is applied here. + fn resolve_motion( + &self, + ch: char, + chars: &[char], + cursor: usize, + len: usize, + ) -> Option { + let count = mul_counts(self.pending.and_then(|p| p.count), self.count); + let m = match ch { + 'h' => Motion::excl(repeat(cursor, count, |c| c.saturating_sub(1))), + 'l' | ' ' => Motion::excl(repeat(cursor, count, |c| (c + 1).min(len))), + '0' => Motion::excl(0), + '$' => Motion::incl(len.saturating_sub(1)), + '^' => Motion::excl(first_non_blank(chars)), + 'w' => Motion::excl(repeat(cursor, count, |c| next_word_start(chars, c, false))), + 'W' => Motion::excl(repeat(cursor, count, |c| next_word_start(chars, c, true))), + 'b' => Motion::excl(repeat(cursor, count, |c| prev_word_start(chars, c, false))), + 'B' => Motion::excl(repeat(cursor, count, |c| prev_word_start(chars, c, true))), + 'e' => Motion::incl(repeat(cursor, count, |c| next_word_end(chars, c, false))), + 'E' => Motion::incl(repeat(cursor, count, |c| next_word_end(chars, c, true))), + 'G' => Motion::incl(len.saturating_sub(1)), + _ => return None, + }; + Some(m) + } + + /// Mode-entry keys and standalone edit commands. + fn run_command( + &mut self, + ch: char, + chars: &[char], + cursor: usize, + len: usize, + count: usize, + ) -> Outcome { + self.reset_pending(); + match ch { + // Mode entry. + 'i' => { + self.mode = Mode::Insert; + Outcome::Move(cursor) + } + 'I' => { + self.mode = Mode::Insert; + Outcome::Move(first_non_blank(chars)) + } + 'a' => { + self.mode = Mode::Insert; + Outcome::Move((cursor + 1).min(len)) + } + 'A' => { + self.mode = Mode::Insert; + Outcome::Move(len) + } + // x / X — delete chars under / before the cursor. + 'x' => { + if cursor >= len { + return Outcome::Noop; + } + let hi = (cursor + count).min(len); + self.register = chars[cursor..hi].iter().collect(); + let text = remove_range(chars, cursor, hi); + Outcome::Replace { + cursor: clamp_normal(cursor, text.chars().count()), + text, + } + } + 'X' => { + if cursor == 0 { + return Outcome::Noop; + } + let lo = cursor.saturating_sub(count); + self.register = chars[lo..cursor].iter().collect(); + let text = remove_range(chars, lo, cursor); + Outcome::Replace { text, cursor: lo } + } + // D / C — to end of line. s / S — substitute. + 'D' => self.apply_operator( + Operator::Delete, + chars, + cursor, + Motion::incl(len.saturating_sub(1)), + len, + ), + 'C' => self.apply_operator( + Operator::Change, + chars, + cursor, + Motion::incl(len.saturating_sub(1)), + len, + ), + 's' => { + if cursor >= len { + self.mode = Mode::Insert; + return Outcome::Move(cursor); + } + let hi = (cursor + count).min(len); + self.register = chars[cursor..hi].iter().collect(); + self.mode = Mode::Insert; + Outcome::Replace { + text: remove_range(chars, cursor, hi), + cursor, + } + } + 'S' => self.apply_linewise(Operator::Change, chars), + // Paste. + 'p' => self.paste(chars, cursor, len, true), + 'P' => self.paste(chars, cursor, len, false), + // Toggle case. + '~' => { + if cursor >= len { + return Outcome::Noop; + } + let hi = (cursor + count).min(len); + let mut out = chars.to_vec(); + for c in out.iter_mut().skip(cursor).take(hi - cursor) { + *c = toggle_case(*c); + } + Outcome::Replace { + text: out.into_iter().collect(), + cursor: clamp_normal(hi, len), + } + } + // Y == yy (linewise yank). + 'Y' => self.apply_linewise(Operator::Yank, chars), + _ => Outcome::Noop, + } + } + + fn paste(&mut self, chars: &[char], cursor: usize, len: usize, after: bool) -> Outcome { + if self.register.is_empty() { + return Outcome::Noop; + } + let reg: Vec = self.register.chars().collect(); + let at = if after && len > 0 { cursor + 1 } else { cursor }; + let at = at.min(len); + let mut out: Vec = chars[..at].to_vec(); + out.extend(reg.iter()); + out.extend(chars[at..].iter()); + Outcome::Replace { + cursor: at + reg.len() - 1, + text: out.into_iter().collect(), + } + } + + /// Apply an operator over the range between `start` and `motion`. + fn apply_operator( + &mut self, + op: Operator, + chars: &[char], + start: usize, + motion: Motion, + len: usize, + ) -> Outcome { + let (lo, hi) = if motion.pos >= start { + (start, (motion.pos + usize::from(motion.inclusive)).min(len)) + } else { + (motion.pos, start) + }; + if lo == hi { + // Empty range: yank/delete nothing. Change still enters insert. + return match op { + Operator::Change => { + self.mode = Mode::Insert; + Outcome::Move(lo) + } + _ => Outcome::Move(clamp_normal(start, len)), + }; + } + match op { + Operator::Yank => { + self.register = chars[lo..hi].iter().collect(); + Outcome::Move(clamp_normal(lo, len)) + } + Operator::Delete => { + self.register = chars[lo..hi].iter().collect(); + let text = remove_range(chars, lo, hi); + Outcome::Replace { + cursor: clamp_normal(lo, text.chars().count()), + text, + } + } + Operator::Change => { + self.register = chars[lo..hi].iter().collect(); + self.mode = Mode::Insert; + Outcome::Replace { + text: remove_range(chars, lo, hi), + cursor: lo, + } + } + } + } + + /// Linewise operator (`dd`/`cc`/`yy`) on the single-line buffer. + fn apply_linewise(&mut self, op: Operator, chars: &[char]) -> Outcome { + self.reset_pending(); + match op { + Operator::Yank => { + self.register = chars.iter().collect(); + Outcome::Move(0) + } + Operator::Delete => { + self.register = chars.iter().collect(); + Outcome::Replace { + text: String::new(), + cursor: 0, + } + } + Operator::Change => { + self.register = chars.iter().collect(); + self.mode = Mode::Insert; + Outcome::Replace { + text: String::new(), + cursor: 0, + } + } + } + } +} + +impl Motion { + fn incl(pos: usize) -> Self { + Motion { + pos, + inclusive: true, + } + } + fn excl(pos: usize) -> Self { + Motion { + pos, + inclusive: false, + } + } +} + +fn is_word_forward(ch: char) -> bool { + matches!(ch, 'w' | 'W') +} + +fn operator_of(ch: char) -> Option { + match ch { + 'd' => Some(Operator::Delete), + 'c' => Some(Operator::Change), + 'y' => Some(Operator::Yank), + _ => None, + } +} + +/// Extract a vi command character: a plain or shifted `Char` key (Ctrl/Alt +/// combinations are not vi command chars). +fn vi_char(key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char(c) + if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => + { + Some(c) + } + _ => None, + } +} + +fn mul_counts(a: Option, b: Option) -> usize { + a.unwrap_or(1) * b.unwrap_or(1) +} + +/// Apply `f` `count` times starting from `start`, stopping early if it reaches +/// a fixed point (so word motions don't spin past the ends). +fn repeat(start: usize, count: usize, f: impl Fn(usize) -> usize) -> usize { + let mut pos = start; + for _ in 0..count.max(1) { + let next = f(pos); + if next == pos { + break; + } + pos = next; + } + pos +} + +fn clamp_normal(pos: usize, len: usize) -> usize { + if len == 0 { + 0 + } else { + pos.min(len - 1) + } +} + +fn remove_range(chars: &[char], lo: usize, hi: usize) -> String { + chars[..lo].iter().chain(chars[hi..].iter()).collect() +} + +fn toggle_case(c: char) -> char { + if c.is_uppercase() { + c.to_lowercase().next().unwrap_or(c) + } else if c.is_lowercase() { + c.to_uppercase().next().unwrap_or(c) + } else { + c + } +} + +fn first_non_blank(chars: &[char]) -> usize { + chars.iter().position(|c| !c.is_whitespace()).unwrap_or(0) +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum Class { + Whitespace, + Word, + Punct, +} + +fn class(c: char, big: bool) -> Class { + if c.is_whitespace() { + Class::Whitespace + } else if big || c.is_alphanumeric() || c == '_' { + // In WORD mode (`big`), every non-whitespace char is part of the word. + Class::Word + } else { + Class::Punct + } +} + +/// vi `w`/`W`: start of the next word after `cursor`. +fn next_word_start(chars: &[char], cursor: usize, big: bool) -> usize { + let len = chars.len(); + let mut i = cursor; + if i >= len { + return len; + } + let c0 = class(chars[i], big); + if c0 != Class::Whitespace { + while i < len && class(chars[i], big) == c0 { + i += 1; + } + } + while i < len && class(chars[i], big) == Class::Whitespace { + i += 1; + } + i.min(len) +} + +/// vi `e`/`E`: end of the next word at or after `cursor`. +fn next_word_end(chars: &[char], cursor: usize, big: bool) -> usize { + let len = chars.len(); + if len == 0 { + return 0; + } + let mut i = cursor + 1; + while i < len && class(chars[i], big) == Class::Whitespace { + i += 1; + } + if i >= len { + return len - 1; + } + let c0 = class(chars[i], big); + while i + 1 < len && class(chars[i + 1], big) == c0 { + i += 1; + } + i +} + +/// vi `b`/`B`: start of the word before `cursor`. +fn prev_word_start(chars: &[char], cursor: usize, big: bool) -> usize { + if cursor == 0 { + return 0; + } + let mut i = cursor - 1; + while i > 0 && class(chars[i], big) == Class::Whitespace { + i -= 1; + } + let c0 = class(chars[i], big); + while i > 0 && class(chars[i - 1], big) == c0 { + i -= 1; + } + i +} + +/// vi `f`/`F`/`t`/`T`: find the `count`-th occurrence of `target`. +fn find_char( + chars: &[char], + cursor: usize, + target: char, + forward: bool, + till: bool, + count: usize, +) -> Option { + let len = chars.len(); + let count = count.max(1); + if forward { + let mut found = 0; + let mut i = cursor; + while i + 1 < len { + i += 1; + if chars[i] == target { + found += 1; + if found == count { + let pos = if till { i - 1 } else { i }; + return Some(Motion::incl(pos)); + } + } + } + None + } else { + let mut found = 0; + let mut i = cursor; + while i > 0 { + i -= 1; + if chars[i] == target { + found += 1; + if found == count { + let pos = if till { i + 1 } else { i }; + return Some(Motion::excl(pos)); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use promkit_widgets::core::crossterm::event::{KeyEvent, KeyEventKind, KeyEventState}; + + fn key(c: char) -> KeyEvent { + let modifiers = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn special(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + /// Feed a sequence of command chars; returns the final (text, cursor, mode). + fn run(initial: &str, cursor: usize, keys: &str) -> (String, usize, Mode) { + let mut ed = Editor::new(Mode::Normal); + let mut text = initial.to_string(); + let mut cur = cursor; + for c in keys.chars() { + match ed.handle_normal(&key(c), &text, cur) { + Outcome::Move(p) => cur = p, + Outcome::Replace { + text: t, + cursor: nc, + } => { + text = t; + cur = nc; + } + Outcome::Noop => {} + } + } + (text, cur, ed.mode) + } + + // --- motions ----------------------------------------------------------- + + #[test] + fn h_l_move_within_bounds() { + assert_eq!(run("abc", 1, "h").1, 0); + assert_eq!(run("abc", 0, "h").1, 0); // clamp at head + assert_eq!(run("abc", 1, "l").1, 2); + assert_eq!(run("abc", 2, "l").1, 2); // clamp at last char + } + + #[test] + fn zero_and_dollar() { + assert_eq!(run("abc def", 5, "0").1, 0); + assert_eq!(run("abc def", 0, "$").1, 6); + } + + #[test] + fn word_forward_backward() { + // "foo bar baz", cursor on 'f' + assert_eq!(run("foo bar baz", 0, "w").1, 4); // -> 'b' of bar + assert_eq!(run("foo bar baz", 0, "ww").1, 8); // -> 'b' of baz + assert_eq!(run("foo bar baz", 8, "b").1, 4); // back to bar + assert_eq!(run("foo bar baz", 4, "e").1, 6); // end of bar + } + + #[test] + fn word_motion_treats_punctuation_as_word() { + // ".foo" : 'w' from '.' should land on 'f' + assert_eq!(run(".foo", 0, "w").1, 1); + // WORD: ".foo bar" from start -> next WORD start 'b' + assert_eq!(run(".foo bar", 0, "W").1, 5); + } + + #[test] + fn count_prefixed_motion() { + assert_eq!(run("a b c d e", 0, "3w").1, 6); // -> 'd' + assert_eq!(run("abcdef", 0, "3l").1, 3); + } + + #[test] + fn gg_and_capital_g() { + assert_eq!(run("abc def", 5, "gg").1, 0); + assert_eq!(run("abc def", 0, "G").1, 6); + } + + // --- find -------------------------------------------------------------- + + #[test] + fn find_char_forward_and_till() { + let mut ed = Editor::new(Mode::Normal); + // f, + assert_eq!(ed.handle_normal(&key('f'), "a,b,c", 0), Outcome::Noop); + assert_eq!(ed.handle_normal(&key(','), "a,b,c", 0), Outcome::Move(1)); + // t, + let mut ed = Editor::new(Mode::Normal); + ed.handle_normal(&key('t'), "a,b,c", 0); + assert_eq!(ed.handle_normal(&key(','), "a,b,c", 0), Outcome::Move(0)); + } + + #[test] + fn find_char_backward() { + let mut ed = Editor::new(Mode::Normal); + ed.handle_normal(&key('F'), "a,b,c", 4); + assert_eq!(ed.handle_normal(&key(','), "a,b,c", 4), Outcome::Move(3)); + } + + // --- edits ------------------------------------------------------------- + + #[test] + fn x_deletes_under_cursor() { + let (t, c, _) = run("abc", 1, "x"); + assert_eq!((t.as_str(), c), ("ac", 1)); + } + + #[test] + fn x_with_count() { + let (t, c, _) = run("abcdef", 1, "3x"); + assert_eq!((t.as_str(), c), ("aef", 1)); + } + + #[test] + fn capital_x_deletes_before() { + let (t, c, _) = run("abc", 2, "X"); + assert_eq!((t.as_str(), c), ("ac", 1)); + } + + #[test] + fn dw_deletes_word() { + let (t, c, _) = run("foo bar", 0, "dw"); + assert_eq!((t.as_str(), c), ("bar", 0)); + } + + #[test] + fn dd_clears_line() { + let (t, c, m) = run("anything here", 4, "dd"); + assert_eq!((t.as_str(), c, m), ("", 0, Mode::Normal)); + } + + #[test] + fn d_dollar_deletes_to_end() { + let (t, c, _) = run("foo bar", 3, "d$"); + assert_eq!((t.as_str(), c), ("foo", 2)); + } + + #[test] + fn capital_d_deletes_to_end() { + let (t, c, _) = run("foo bar", 3, "D"); + assert_eq!((t.as_str(), c), ("foo", 2)); + } + + #[test] + fn de_deletes_to_word_end_inclusive() { + let (t, c, _) = run("foo bar", 0, "de"); + assert_eq!((t.as_str(), c), (" bar", 0)); + } + + #[test] + fn cw_acts_like_ce_and_enters_insert() { + let (t, c, m) = run("foo bar", 0, "cw"); + assert_eq!((t.as_str(), c, m), (" bar", 0, Mode::Insert)); + } + + #[test] + fn cc_clears_and_inserts() { + let (t, c, m) = run("foo bar", 3, "cc"); + assert_eq!((t.as_str(), c, m), ("", 0, Mode::Insert)); + } + + #[test] + fn df_deletes_through_char() { + let mut ed = Editor::new(Mode::Normal); + ed.handle_normal(&key('d'), "a,b,c", 0); + ed.handle_normal(&key('f'), "a,b,c", 0); + let out = ed.handle_normal(&key(','), "a,b,c", 0); + assert_eq!( + out, + Outcome::Replace { + text: "b,c".to_string(), + cursor: 0 + } + ); + } + + #[test] + fn count_with_operator() { + let (t, c, _) = run("a b c d e", 0, "d3w"); + assert_eq!((t.as_str(), c), ("d e", 0)); + } + + // --- insert mode entry ------------------------------------------------- + + #[test] + fn i_a_capital_i_a_enter_insert() { + assert_eq!(run("abc", 1, "i"), ("abc".to_string(), 1, Mode::Insert)); + assert_eq!(run("abc", 1, "a"), ("abc".to_string(), 2, Mode::Insert)); + assert_eq!(run(" abc", 4, "I"), (" abc".to_string(), 2, Mode::Insert)); + assert_eq!(run("abc", 0, "A"), ("abc".to_string(), 3, Mode::Insert)); + } + + #[test] + fn leave_insert_steps_left() { + let mut ed = Editor::new(Mode::Insert); + assert_eq!(ed.leave_insert(3), 2); + assert_eq!(ed.mode, Mode::Normal); + assert_eq!(ed.leave_insert(0), 0); + } + + // --- replace / toggle / paste ----------------------------------------- + + #[test] + fn r_replaces_char() { + let mut ed = Editor::new(Mode::Normal); + ed.handle_normal(&key('r'), "abc", 1); + let out = ed.handle_normal(&key('X'), "abc", 1); + assert_eq!( + out, + Outcome::Replace { + text: "aXc".to_string(), + cursor: 1 + } + ); + } + + #[test] + fn tilde_toggles_case_and_advances() { + let (t, c, _) = run("abc", 0, "~"); + assert_eq!((t.as_str(), c), ("Abc", 1)); + } + + #[test] + fn yank_and_paste() { + // yw yanks "foo ", p pastes after cursor. + let mut ed = Editor::new(Mode::Normal); + let mut text = "foo bar".to_string(); + let mut cur = 0; + for c in "yw".chars() { + match ed.handle_normal(&key(c), &text, cur) { + Outcome::Move(p) => cur = p, + Outcome::Replace { + text: t, + cursor: nc, + } => { + text = t; + cur = nc; + } + Outcome::Noop => {} + } + } + // cursor back at 0; paste after. + let out = ed.handle_normal(&key('p'), &text, cur); + assert_eq!( + out, + Outcome::Replace { + text: "ffoo oo bar".to_string(), + cursor: 4 + } + ); + } + + #[test] + fn delete_fills_register_for_paste() { + let mut ed = Editor::new(Mode::Normal); + // x deletes 'a' into register, then p pastes it after cursor. + let out = ed.handle_normal(&key('x'), "abc", 0); + assert_eq!( + out, + Outcome::Replace { + text: "bc".to_string(), + cursor: 0 + } + ); + let out = ed.handle_normal(&key('p'), "bc", 0); + assert_eq!( + out, + Outcome::Replace { + text: "bac".to_string(), + cursor: 1 + } + ); + } + + // --- navigation keys / cancel ----------------------------------------- + + #[test] + fn arrow_keys_move() { + let mut ed = Editor::new(Mode::Normal); + assert_eq!( + ed.handle_normal(&special(KeyCode::Left), "abc", 2), + Outcome::Move(1) + ); + assert_eq!( + ed.handle_normal(&special(KeyCode::Right), "abc", 0), + Outcome::Move(1) + ); + } + + #[test] + fn esc_cancels_pending_operator() { + let mut ed = Editor::new(Mode::Normal); + ed.handle_normal(&key('d'), "abc", 0); + assert!(ed.is_pending()); + assert_eq!( + ed.handle_normal(&special(KeyCode::Esc), "abc", 0), + Outcome::Noop + ); + assert!(!ed.is_pending()); + } + + #[test] + fn empty_buffer_is_safe() { + assert_eq!(run("", 0, "x"), ("".to_string(), 0, Mode::Normal)); + assert_eq!(run("", 0, "dw"), ("".to_string(), 0, Mode::Normal)); + assert_eq!(run("", 0, "$").1, 0); + let (_, _, m) = run("", 0, "i"); + assert_eq!(m, Mode::Insert); + } +}