From cdc4e7f96159ea5b82e25087bed7ea809ad4efd9 Mon Sep 17 00:00:00 2001 From: kronberger-droid Date: Thu, 5 Mar 2026 12:07:47 +0100 Subject: [PATCH 01/15] feat: add Helix edit mode behind `hx` feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Helix-inspired edit mode with three modes (Normal, Insert, Select) and Helix-style selection semantics where every motion creates or extends a selection. Key features: - HxRange (anchor + head) selection model with gap indexing - Word motions (w/b/e/W/B/E) with Unicode-aware boundary detection - Character find/til motions (f/t/F/T) with skip-current-char semantics - Selection manipulation: delete (d), change (c), yank (y), paste (p/P), replace (r), switch case (~), flip (o) - Insert mode via i/a (selection-preserving) and I/A (line start/end) - Select mode (v) with extending motions - Count prefix support for motions, capped at 100k - Cursor shape configuration per mode via CursorConfig::with_hx_defaults() - Prompt integration with PromptEditMode::Helix and PromptHelixMode All new code is gated behind #[cfg(feature = "hx")] — no changes to existing Vi or Emacs behavior. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 5 + examples/demo.rs | 1 + examples/helix.rs | 30 + src/core_editor/editor.rs | 1157 ++++++++++++++++++- src/core_editor/line_buffer.rs | 64 +- src/core_editor/mod.rs | 2 + src/edit_mode/cursors.rs | 109 ++ src/edit_mode/hx/mod.rs | 1941 ++++++++++++++++++++++++++++++++ src/edit_mode/hx/word.rs | 792 +++++++++++++ src/edit_mode/mod.rs | 6 + src/engine.rs | 10 + src/enums.rs | 156 +++ src/lib.rs | 8 + src/painting/painter.rs | 12 + src/prompt/base.rs | 23 + src/prompt/default.rs | 69 ++ src/prompt/mod.rs | 2 + 17 files changed, 4376 insertions(+), 11 deletions(-) create mode 100644 examples/helix.rs create mode 100644 src/edit_mode/hx/mod.rs create mode 100644 src/edit_mode/hx/word.rs diff --git a/Cargo.toml b/Cargo.toml index 41bbb401e..61a5406e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,11 @@ sqlite = ["rusqlite/bundled", "serde_json"] sqlite-dynlib = ["rusqlite", "serde_json"] system_clipboard = ["arboard"] libc = ["crossterm/libc"] +hx = [] + +[[example]] +name = "helix" +required-features = ["hx"] [[example]] name = "cwd_aware_hinter" diff --git a/examples/demo.rs b/examples/demo.rs index 44e7b9217..eec99b7ac 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -77,6 +77,7 @@ fn main() -> reedline::Result<()> { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), emacs: None, + ..CursorConfig::default() }; let mut line_editor = Reedline::create() diff --git a/examples/helix.rs b/examples/helix.rs new file mode 100644 index 000000000..f6aabce30 --- /dev/null +++ b/examples/helix.rs @@ -0,0 +1,30 @@ +// Interactive Helix edit mode sandbox. +// cargo run --features=hx --example helix + +use reedline::{CursorConfig, DefaultPrompt, Helix, Reedline, Signal}; +use std::io; + +fn main() -> io::Result<()> { + let mut line_editor = Reedline::create() + .with_edit_mode(Box::new(Helix::default())) + .with_cursor_config(CursorConfig::with_hx_defaults()); + let prompt = DefaultPrompt::default(); + + println!("Helix edit mode demo. Starts in Normal mode."); + println!(" i = Insert, Esc = Normal, v = Select"); + println!(" h/l = left/right, w/b/e = word motions"); + println!(" Ctrl+C or Ctrl+D to exit\n"); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + } + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + } + } + } +} diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index a7961aaa8..502961ebc 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,11 +1,15 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; +#[cfg(feature = "hx")] +use crate::edit_mode::hx::word; use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; use crate::prompt::{PromptEditMode, PromptViMode}; use crate::{core_editor::get_local_clipboard, EditCommand}; use std::cmp::{max, min}; use std::ops::{DerefMut, Range}; +#[cfg(feature = "hx")] +use unicode_segmentation::UnicodeSegmentation; /// Stateful editor executing changes to the underlying [`LineBuffer`] /// @@ -21,6 +25,8 @@ pub struct Editor { selection_anchor: Option, selection_mode: Option, edit_mode: PromptEditMode, + #[cfg(feature = "hx")] + hx_selection: Option, } impl Default for Editor { @@ -35,6 +41,73 @@ impl Default for Editor { selection_anchor: None, selection_mode: None, edit_mode: PromptEditMode::Default, + #[cfg(feature = "hx")] + hx_selection: None, + } + } +} + +// ── Helix selection range ───────────────────────────────────────────── + +/// A single selection range with anchor and head. +/// +/// Both `anchor` and `head` are byte offsets into the buffer. +/// The anchor is where the selection started; the head is where +/// the cursor currently sits. Uses gap indexing (left-inclusive, +/// right-exclusive), matching Helix semantics. +#[cfg(feature = "hx")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HxRange { + pub(crate) anchor: usize, + pub(crate) head: usize, +} + +#[cfg(feature = "hx")] +impl HxRange { + /// Ascending byte range for slicing/rendering. + /// Returns (min, max) of anchor and head. + #[must_use] + pub fn range(&self) -> (usize, usize) { + (min(self.head, self.anchor), max(self.head, self.anchor)) + } + + /// Clamp anchor and head so they lie on char boundaries within `buf`. + /// Selections can become stale after buffer mutations (undo, delete, paste). + pub fn clamp(&mut self, buf: &str) { + let len = buf.len(); + self.anchor = Self::snap_to_char_boundary(buf, min(self.anchor, len)); + self.head = Self::snap_to_char_boundary(buf, min(self.head, len)); + } + + /// Round a byte offset down to the nearest char boundary in `buf`. + fn snap_to_char_boundary(buf: &str, offset: usize) -> usize { + if offset >= buf.len() { + return buf.len(); + } + // Walk backwards until we find a char boundary. + let mut pos = offset; + while !buf.is_char_boundary(pos) && pos > 0 { + pos -= 1; + } + pos + } + + /// Block cursor position: the byte offset where the cursor + /// should be rendered. For a forward range the cursor sits + /// one grapheme before head; for a backward or empty range + /// it sits at head. + #[must_use] + pub fn cursor(&self, buf: &str) -> usize { + // Clamp head to buffer length to avoid panic on stale selections. + let head = min(self.head, buf.len()); + let anchor = min(self.anchor, buf.len()); + if head > anchor { + buf[..head] + .grapheme_indices(true) + .next_back() + .map_or(head, |(i, _)| i) + } else { + head } } } @@ -196,11 +269,52 @@ impl Editor { EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), + #[cfg(feature = "hx")] + EditCommand::HxRestartSelection => self.hx_restart_selection(), + #[cfg(feature = "hx")] + EditCommand::HxClearSelection => self.reset_hx_state(), + #[cfg(feature = "hx")] + EditCommand::HxEnsureSelection => self.hx_ensure_selection(), + #[cfg(feature = "hx")] + EditCommand::HxSyncCursor => self.hx_sync_cursor(), + #[cfg(feature = "hx")] + EditCommand::HxSyncCursorWithRestart => self.hx_sync_cursor_with_restart(), + #[cfg(feature = "hx")] + EditCommand::HxWordMotion { + target, + movement, + count, + } => self.hx_word_motion(*target, *movement, *count), + #[cfg(feature = "hx")] + EditCommand::HxFlipSelection => self.hx_flip_selection(), + #[cfg(feature = "hx")] + EditCommand::HxMoveToSelectionStart => self.hx_move_to_selection_start(), + #[cfg(feature = "hx")] + EditCommand::HxMoveToSelectionEnd => self.hx_move_to_selection_end(), + #[cfg(feature = "hx")] + EditCommand::HxSwitchCaseSelection => self.hx_switch_case_selection(), + #[cfg(feature = "hx")] + EditCommand::HxReplaceSelectionWithChar(c) => self.hx_replace_selection_with_char(*c), + #[cfg(feature = "hx")] + EditCommand::HxDeleteSelection => self.hx_delete_selection(), + #[cfg(feature = "hx")] + EditCommand::HxExtendSelectionToInsertionPoint => { + self.hx_extend_selection_to_insertion_point() + } + #[cfg(feature = "hx")] + EditCommand::HxShiftSelectionToInsertionPoint => { + self.hx_shift_selection_to_insertion_point() + } } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { self.clear_selection(); } - if let EditType::MoveCursor { select: true } = command.edit_type() {} + + // NoOp commands (e.g. Hx selection bookkeeping) must not touch the undo + // stack at all — otherwise they corrupt the redo chain after an undo. + if command.edit_type() == EditType::NoOp { + return; + } let new_undo_behavior = match (command, command.edit_type()) { (_, EditType::MoveCursor { .. }) => UndoBehavior::MoveCursor, @@ -230,6 +344,343 @@ impl Editor { pub(crate) fn clear_selection(&mut self) { self.selection_anchor = None; self.selection_mode = None; + // NOTE: hx_selection is NOT cleared here. Helix mode manages its own + // selection lifecycle via reset_hx_state() / hx_restart_selection(). + } + + /// Disable Helix selection tracking (e.g. when switching away from Helix mode). + #[cfg(feature = "hx")] + pub(crate) fn reset_hx_state(&mut self) { + self.hx_selection = None; + } + + /// Reset the hx selection to a 1-grapheme-wide range at the current + /// insertion point, matching Helix's invariant that selections always + /// cover at least one grapheme. + /// + /// On an empty buffer, clears the selection instead (no grapheme to + /// select). + #[cfg(feature = "hx")] + pub(crate) fn hx_restart_selection(&mut self) { + if self.line_buffer.get_buffer().is_empty() { + self.hx_selection = None; + return; + } + let pos = self.insertion_point(); + let next = self.line_buffer.grapheme_right_index_from_pos(pos); + self.hx_selection = Some(HxRange { + anchor: pos, + head: next, + }); + } + + /// Ensure an hx selection exists. If one already exists, leave it + /// untouched; otherwise create a collapsed selection at the cursor. + #[cfg(feature = "hx")] + pub(crate) fn hx_ensure_selection(&mut self) { + if self.hx_selection.is_none() { + self.hx_restart_selection(); + } + } + + /// Implements Helix `put_cursor` semantics for Select mode. + /// + /// Reads the current insertion point (where the motion landed), then: + /// 1. Adjusts the anchor if the selection direction flipped + /// (Helix "1-width" block-cursor semantics). + /// 2. Sets head: for forward selections, one grapheme past the + /// cursor position; for backward, at the cursor position. + /// 3. Sets the insertion point to `sel.cursor()` for display. + /// + /// Motions start from `cursor()` (the display position), which is + /// consistent with Helix's `move_horizontally`. + #[cfg(feature = "hx")] + pub(crate) fn hx_sync_cursor(&mut self) { + let pos = self.insertion_point(); + if let Some(sel) = &mut self.hx_selection { + let buf = self.line_buffer.get_buffer(); + sel.clamp(buf); + + // Anchor adjustment when direction flips. + if sel.head >= sel.anchor && pos < sel.anchor { + // Was forward, now backward: advance anchor by 1 grapheme. + sel.anchor = buf[sel.anchor..] + .grapheme_indices(true) + .nth(1) + .map_or(buf.len(), |(i, _)| sel.anchor + i); + } else if sel.head < sel.anchor && pos >= sel.anchor { + // Was backward, now forward: retreat anchor by 1 grapheme. + sel.anchor = buf[..sel.anchor] + .grapheme_indices(true) + .next_back() + .map_or(0, |(i, _)| i); + } + + // Set head with 1-width semantics. + if pos >= sel.anchor { + sel.head = buf[pos..] + .grapheme_indices(true) + .nth(1) + .map_or(buf.len(), |(i, _)| pos + i); + } else { + sel.head = pos; + } + + self.line_buffer.set_insertion_point(sel.cursor(buf)); + } + } + + /// Atomic restart + sync for extending motions in Normal mode. + /// + /// Compares the current insertion point with the existing selection's + /// display cursor. If they differ (the motion moved), restarts the + /// anchor at the old cursor position and syncs the head to the new + /// position. If they match (the motion was a no-op), the selection + /// is left unchanged — matching Helix's `map_or(range, ...)` pattern. + /// + /// Direction-flip logic (as in `hx_sync_cursor`) is intentionally + /// absent here: restart always creates a fresh anchor at the old cursor + /// position, so the selection is either strictly forward (motion moved + /// right) or strictly backward (motion moved left) — never a flip from + /// a prior extended selection. + #[cfg(feature = "hx")] + pub(crate) fn hx_sync_cursor_with_restart(&mut self) { + let pos = self.insertion_point(); + if let Some(sel) = &mut self.hx_selection { + let buf = self.line_buffer.get_buffer(); + sel.clamp(buf); + + let old_cursor = sel.cursor(buf); + if pos == old_cursor { + // Motion made no progress — leave the selection as-is. + return; + } + + // Restart: collapse to a point at the old cursor, then extend to + // the new position. This matches Helix's + // Range::point(cursor).put_cursor(text, pos, true) + // When the motion goes backward (pos < cursor), advance the + // anchor by one grapheme so the cursor character is included. + if pos < old_cursor { + sel.anchor = buf[old_cursor..] + .grapheme_indices(true) + .nth(1) + .map_or(buf.len(), |(i, _)| old_cursor + i); + sel.head = pos; + } else { + sel.anchor = old_cursor; + sel.head = buf[pos..] + .grapheme_indices(true) + .nth(1) + .map_or(buf.len(), |(i, _)| pos + i); + } + + self.line_buffer.set_insertion_point(sel.cursor(buf)); + } else { + // No selection exists yet; create one. + self.hx_restart_selection(); + } + } + + /// Get a reference to the current Helix selection, if any. + /// + /// Exposes the full [`HxRange`] with anchor/head distinction, unlike + /// [`get_selection`](Self::get_selection) which only returns `(min, max)`. + #[cfg(feature = "hx")] + #[allow(dead_code)] // available for internal use; currently used in tests + pub(crate) fn hx_selection(&self) -> Option<&HxRange> { + self.hx_selection.as_ref() + } + + #[cfg(feature = "hx")] + #[cfg(test)] + pub(crate) fn set_hx_selection(&mut self, sel: HxRange) { + self.hx_selection = Some(sel); + } + + /// Run a word motion that takes and returns an HxRange. + /// + /// For `Movement::Move` (Normal mode): restarts the selection at the + /// current cursor position before computing the motion. + /// For `Movement::Extend` (Select mode): operates on the existing + /// selection with anchor preserved. + /// + /// If the motion makes no progress (cursor position unchanged), + /// the existing selection is preserved — matching Helix behavior + /// where a failed motion at end-of-buffer keeps the last selection. + #[cfg(feature = "hx")] + fn hx_word_motion( + &mut self, + target: crate::enums::WordMotionTarget, + movement: crate::enums::Movement, + count: usize, + ) { + let buf = self.line_buffer.get_buffer(); + let pos = self.insertion_point(); + let next = self.line_buffer.grapheme_right_index_from_pos(pos); + let mut sel = self.hx_selection.unwrap_or(HxRange { + anchor: pos, + head: next, + }); + sel.clamp(buf); + + // For Move mode, restart selection at cursor before the motion. + let input_sel = match movement { + crate::enums::Movement::Move => { + let cursor_pos = sel.cursor(buf); + let cursor_next = self.line_buffer.grapheme_right_index_from_pos(cursor_pos); + HxRange { + anchor: cursor_pos, + head: cursor_next, + } + } + crate::enums::Movement::Extend => sel, + }; + + let new = word::word_move(buf, &input_sel, count, target); + + // If the cursor position didn't change, the motion made no + // progress (e.g. at end-of-buffer). Keep the existing selection. + if new.cursor(buf) == sel.cursor(buf) { + return; + } + + self.line_buffer.set_insertion_point(new.cursor(buf)); + self.hx_selection = Some(new); + } + + /// Swap anchor and head of the Helix selection, updating the cursor. + #[cfg(feature = "hx")] + fn hx_flip_selection(&mut self) { + if let Some(sel) = &mut self.hx_selection { + sel.clamp(self.line_buffer.get_buffer()); + std::mem::swap(&mut sel.anchor, &mut sel.head); + let buf = self.line_buffer.get_buffer(); + self.line_buffer.set_insertion_point(sel.cursor(buf)); + } + } + + /// Move cursor to the ascending start of the Helix selection. + #[cfg(feature = "hx")] + fn hx_move_to_selection_start(&mut self) { + if let Some(sel) = &mut self.hx_selection { + sel.clamp(self.line_buffer.get_buffer()); + self.line_buffer.set_insertion_point(sel.range().0); + } + } + + /// Move cursor past the ascending end of the Helix selection. + #[cfg(feature = "hx")] + fn hx_move_to_selection_end(&mut self) { + if let Some(sel) = &mut self.hx_selection { + sel.clamp(self.line_buffer.get_buffer()); + self.line_buffer.set_insertion_point(sel.range().1); + } + } + + /// Transform the text inside the Helix selection, then update the + /// selection to cover the (possibly resized) replacement. + #[cfg(feature = "hx")] + fn hx_transform_selection(&mut self, transform: impl FnOnce(&str) -> String) { + if let Some(sel) = &mut self.hx_selection { + sel.clamp(self.line_buffer.get_buffer()); + let (start, end) = sel.range(); + let selected = &self.line_buffer.get_buffer()[start..end]; + let result = transform(selected); + let new_end = start + result.len(); + self.line_buffer.clear_range_safe(start..end); + self.line_buffer.set_insertion_point(start); + self.line_buffer.insert_str(&result); + // Preserve selection direction over the new content. + if sel.anchor <= sel.head { + sel.anchor = start; + sel.head = new_end; + } else { + sel.anchor = new_end; + sel.head = start; + } + // Restore cursor to the display position within the updated selection. + let buf = self.line_buffer.get_buffer(); + self.line_buffer.set_insertion_point(sel.cursor(buf)); + } + } + + /// Toggle case of every character in the Helix selection. + #[cfg(feature = "hx")] + fn hx_switch_case_selection(&mut self) { + self.hx_transform_selection(|selected| { + selected + .chars() + .flat_map(|c| { + if c.is_lowercase() { + c.to_uppercase().collect::>() + } else { + c.to_lowercase().collect::>() + } + }) + .collect() + }); + } + + /// Replace every character in the Helix selection with the given char. + /// Counts characters (not grapheme clusters) to match Helix behavior. + #[cfg(feature = "hx")] + fn hx_replace_selection_with_char(&mut self, c: char) { + self.hx_transform_selection(|selected| { + let char_count = selected.chars().count(); + std::iter::repeat(c).take(char_count).collect() + }); + } + + /// Delete the Helix selection range without saving to the cut buffer. + /// Clears hx_selection afterwards so subsequent commands see no selection. + #[cfg(feature = "hx")] + fn hx_delete_selection(&mut self) { + if let Some(mut sel) = self.hx_selection.take() { + sel.clamp(self.line_buffer.get_buffer()); + let (start, end) = sel.range(); + self.line_buffer.clear_range_safe(start..end); + self.line_buffer.set_insertion_point(start); + } + } + + /// Extend the Helix selection head to the current insertion point. + /// Used in `a` (append) insert mode so the selection grows as the + /// user types. The anchor is pinned to the selection start (min side) + /// and head tracks the cursor forward. This normalization ensures + /// backward selections are handled correctly: a backward selection + /// (anchor > head) is flipped so anchor holds the start before extending. + #[cfg(feature = "hx")] + fn hx_extend_selection_to_insertion_point(&mut self) { + let pos = self.insertion_point(); + if let Some(sel) = &mut self.hx_selection { + sel.anchor = sel.range().0; + sel.head = pos; + } + } + + /// Shift both anchor and head of the Helix selection so the selection + /// tracks the same text after an edit before it. + /// + /// Called after `InsertChar` or `Backspace` in `i` (insert-before) mode. + /// The cursor sits at the selection start; after the edit it has moved + /// forward (insert) or backward (backspace). We compute the delta as + /// `insertion_point − selection_start` and apply it to both ends. + #[cfg(feature = "hx")] + fn hx_shift_selection_to_insertion_point(&mut self) { + let pos = self.insertion_point(); + if let Some(sel) = &mut self.hx_selection { + let start = sel.range().0; + if pos > start { + let shift = pos - start; + sel.anchor += shift; + sel.head += shift; + } else if pos < start { + let shift = start - pos; + sel.anchor = sel.anchor.saturating_sub(shift); + sel.head = sel.head.saturating_sub(shift); + } + } } fn update_selection_anchor(&mut self, select: bool) { @@ -247,6 +698,18 @@ impl Editor { pub fn set_edit_mode(&mut self, mode: PromptEditMode) { self.edit_mode = mode; } + + /// Check if the editor is currently in Helix edit mode. + fn is_hx_mode(&self) -> bool { + #[cfg(feature = "hx")] + { + matches!(self.edit_mode, PromptEditMode::Helix(_)) + } + #[cfg(not(feature = "hx"))] + { + false + } + } fn move_to_position(&mut self, position: usize, select: bool) { self.update_selection_anchor(select); self.line_buffer.set_insertion_point(position) @@ -544,7 +1007,8 @@ impl Editor { ) { self.update_selection_anchor(select); if before_char { - self.line_buffer.move_right_before(c, current_line); + let skip = self.is_hx_mode(); + self.line_buffer.move_right_before(c, current_line, skip); } else { self.line_buffer.move_right_until(c, current_line); } @@ -559,7 +1023,8 @@ impl Editor { ) { self.update_selection_anchor(select); if before_char { - self.line_buffer.move_left_before(c, current_line); + let skip = self.is_hx_mode(); + self.line_buffer.move_left_before(c, current_line, skip); } else { self.line_buffer.move_left_until(c, current_line); } @@ -641,6 +1106,8 @@ impl Editor { self.system_clipboard.set(cut_slice, ClipboardMode::Normal); self.cut_range(start..end); self.clear_selection(); + #[cfg(feature = "hx")] + self.reset_hx_state(); } } @@ -648,6 +1115,8 @@ impl Editor { if let Some((start, end)) = self.get_selection() { self.cut_range(start..end); self.clear_selection(); + #[cfg(feature = "hx")] + self.reset_hx_state(); } } @@ -668,7 +1137,24 @@ impl Editor { /// If a selection is active returns the selected range, otherwise None. /// The range is guaranteed to be ascending. + /// + /// # Helix selection design note + /// + /// When `hx` is enabled and `hx_selection` is `Some`, this returns the + /// hx range with priority. This is correct for **read-only** consumers + /// (painter highlighting, `CopySelection`, `CutSelection`) but + /// **`delete_selection()` deliberately ignores it** via the + /// `selection_anchor.is_none()` early return. The reason: Helix keeps + /// the selection visible as context during Insert mode — implicit + /// deletion by `insert_char`/`backspace` etc. must not consume it. + /// Only explicit Helix commands (`CutSelection`, `HxDeleteSelection`) + /// should delete the hx-selected text. pub fn get_selection(&self) -> Option<(usize, usize)> { + #[cfg(feature = "hx")] + if let Some(sel) = &self.hx_selection { + return Some(sel.range()); + } + let selection_anchor = self.selection_anchor?; // Use the mode that was active when the selection was created, not the current mode @@ -705,9 +1191,20 @@ impl Editor { } fn delete_selection(&mut self) { + // Only delete based on legacy selection_anchor (Vi visual mode, + // Emacs mark). Helix mode manages its own selection deletion via + // explicit commands (CutSelection, HxDeleteSelection) — the + // hx_selection must NOT be consumed implicitly by insert_char, + // backspace, etc., because Helix keeps the selection visible as + // context while typing in insert mode. + if self.selection_anchor.is_none() { + return; + } if let Some((start, end)) = self.get_selection() { self.line_buffer.clear_range_safe(start..end); self.clear_selection(); + #[cfg(feature = "hx")] + self.reset_hx_state(); } } @@ -2145,4 +2642,658 @@ mod test { assert_eq!(bracket_result, expected_bracket); assert_eq!(quote_result, expected_quote); } + + #[cfg(feature = "hx")] + mod hx_selection_tests { + use super::{editor_with, HxRange}; + use crate::prompt::{PromptEditMode, PromptHelixMode}; + use crate::EditCommand; + use pretty_assertions::assert_eq; + + #[test] + fn restart_sets_anchor_and_head() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(3); + editor.hx_restart_selection(); + let sel = editor.hx_selection().unwrap(); + assert_eq!(sel.anchor, 3); + // 1-grapheme-wide: head is one grapheme past anchor + assert_eq!(sel.head, 4); + assert_eq!(editor.get_selection(), Some((3, 4))); + } + + #[test] + fn sync_cursor_extends_selection_forward() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.hx_restart_selection(); + // Simulate a motion landing at position 5 + editor.line_buffer.set_insertion_point(5); + editor.hx_sync_cursor(); + let sel = editor.hx_selection().unwrap(); + assert_eq!(sel.anchor, 0); + // Forward range: head is one grapheme past cursor (1-width semantics) + assert_eq!(sel.head, 6); + assert_eq!(editor.get_selection(), Some((0, 6))); + } + + #[test] + fn restart_after_sync_resets_both() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.hx_restart_selection(); + editor.line_buffer.set_insertion_point(5); + editor.hx_sync_cursor(); + // Now restart at the cursor display position + editor.hx_restart_selection(); + let sel = editor.hx_selection().unwrap(); + assert_eq!(sel.anchor, 5); + // 1-grapheme-wide: head is one grapheme past anchor + assert_eq!(sel.head, 6); + } + + #[test] + fn sync_cursor_backward_selection() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(5); + editor.hx_restart_selection(); + // Simulate a motion landing at position 2 + editor.line_buffer.set_insertion_point(2); + editor.hx_sync_cursor(); + let sel = editor.hx_selection().unwrap(); + // Backward: anchor shifts forward by 1 grapheme (direction flip) + assert_eq!(sel.anchor, 6); + assert_eq!(sel.head, 2); + assert!(sel.head < sel.anchor); + } + + #[test] + fn reset_hx_state_clears_hx() { + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(0); + editor.hx_restart_selection(); + editor.reset_hx_state(); + assert!(editor.hx_selection().is_none()); + assert_eq!(editor.get_selection(), None); + } + + #[test] + fn hx_selection_takes_priority_over_legacy() { + let mut editor = editor_with("hello world"); + // Set legacy selection + editor.selection_anchor = Some(0); + editor.line_buffer.set_insertion_point(3); + // Set hx selection to different range + editor.hx_selection = Some(HxRange { anchor: 1, head: 4 }); + // hx_selection should win + assert_eq!(editor.get_selection(), Some((1, 4))); + } + + #[test] + fn ensure_selection_creates_when_none() { + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(2); + assert!(editor.hx_selection().is_none()); + editor.hx_ensure_selection(); + let sel = editor.hx_selection().unwrap(); + assert_eq!(sel.anchor, 2); + assert_eq!(sel.head, 3); + } + + #[test] + fn ensure_selection_preserves_existing() { + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_ensure_selection(); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 5)); + } + + #[test] + fn flip_selection_swaps_anchor_and_head() { + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_flip_selection(); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (5, 0)); + } + + #[test] + fn move_to_selection_start_forward() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(8); + editor.hx_selection = Some(HxRange { anchor: 2, head: 7 }); + editor.hx_move_to_selection_start(); + assert_eq!(editor.insertion_point(), 2); + } + + #[test] + fn move_to_selection_start_backward() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.hx_selection = Some(HxRange { anchor: 7, head: 2 }); + editor.hx_move_to_selection_start(); + assert_eq!(editor.insertion_point(), 2); + } + + #[test] + fn move_to_selection_end_forward() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.hx_selection = Some(HxRange { anchor: 2, head: 7 }); + editor.hx_move_to_selection_end(); + assert_eq!(editor.insertion_point(), 7); + } + + #[test] + fn switch_case_toggles() { + let mut editor = editor_with("Hello"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_switch_case_selection(); + assert_eq!(editor.get_buffer(), "hELLO"); + // Selection should still cover the full word + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 5)); + } + + #[test] + fn replace_selection_with_char() { + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_replace_selection_with_char('x'); + assert_eq!(editor.get_buffer(), "xxxxx world"); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 5)); + } + + #[test] + fn delete_selection_removes_range() { + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 6 }); + editor.hx_delete_selection(); + assert_eq!(editor.get_buffer(), "world"); + assert_eq!(editor.insertion_point(), 0); + assert!(editor.hx_selection().is_none()); + } + + #[test] + fn delete_selection_backward_range() { + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 6, head: 0 }); + editor.hx_delete_selection(); + assert_eq!(editor.get_buffer(), "world"); + assert_eq!(editor.insertion_point(), 0); + } + + #[test] + fn restart_on_empty_buffer_clears() { + let mut editor = editor_with(""); + editor.hx_restart_selection(); + assert!(editor.hx_selection().is_none()); + } + + // ── sync_cursor direction flip tests ──────────────────────────── + + #[test] + fn sync_cursor_backward_to_forward_flip() { + // Start with backward selection (anchor > head), then move forward past anchor. + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(5); + editor.hx_restart_selection(); + // Force backward: simulate motion landing at 2 + editor.line_buffer.set_insertion_point(2); + editor.hx_sync_cursor(); + let sel = editor.hx_selection().unwrap(); + assert!(sel.head < sel.anchor, "should be backward"); + + // Now simulate motion landing past the (adjusted) anchor + editor.line_buffer.set_insertion_point(8); + editor.hx_sync_cursor(); + let sel = editor.hx_selection().unwrap(); + assert!( + sel.head > sel.anchor, + "should flip to forward: anchor={} head={}", + sel.anchor, + sel.head + ); + } + + // ── transform_selection with length change ────────────────────── + + #[test] + fn replace_selection_with_char_multibyte_shrinks() { + // Replace multi-byte chars with single-byte — selection should shrink. + let mut editor = editor_with("café world"); + // "café" = bytes [0..5) (é is 2 bytes), 4 chars + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_replace_selection_with_char('x'); + // 4 chars → 4 'x' = 4 bytes + assert_eq!(editor.get_buffer(), "xxxx world"); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 4)); + } + + #[test] + fn replace_selection_with_multibyte_char_expands() { + // Replace ASCII selection with a multi-byte char — selection should expand. + let mut editor = editor_with("hello world"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + // 'é' is 2 bytes; 5 chars × 2 bytes = 10 bytes + editor.hx_replace_selection_with_char('é'); + assert_eq!(editor.get_buffer(), "ééééé world"); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 10)); + } + + #[test] + fn replace_counts_chars_not_graphemes() { + // "e\u{0301}" is 2 chars (e + combining acute) but 1 grapheme cluster. + // Replace should count chars, matching Helix behavior. + let mut editor = editor_with("e\u{0301}!"); + // "e\u{0301}" = 3 bytes (e=1, combining=2), "!" = 1 byte → total 4 bytes + // Selection covers "e\u{0301}!" = 4 bytes, 3 chars + editor.hx_selection = Some(HxRange { anchor: 0, head: 4 }); + editor.hx_replace_selection_with_char('x'); + // 3 chars → "xxx" = 3 bytes + assert_eq!(editor.get_buffer(), "xxx"); + let sel = editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 3)); + } + + #[test] + fn switch_case_preserves_multibyte() { + let mut editor = editor_with("café"); + editor.hx_selection = Some(HxRange { anchor: 0, head: 5 }); + editor.hx_switch_case_selection(); + assert_eq!(editor.get_buffer(), "CAFÉ"); + let sel = editor.hx_selection().unwrap(); + // É is also 2 bytes, so length stays the same + assert_eq!((sel.anchor, sel.head), (0, 5)); + } + + // ── Full edit sequences through run_edit_command ──────────────── + + #[test] + fn word_motion_then_delete() { + use crate::enums::{Movement, WordMotionTarget}; + let mut editor = editor_with("hello world test"); + editor.line_buffer.set_insertion_point(0); + // w: select "hello " (Normal mode = Move) + editor.run_edit_command(&EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }); + let sel = editor.hx_selection().unwrap(); + let selected = &editor.get_buffer()[sel.range().0..sel.range().1]; + assert_eq!(selected, "hello "); + // d: cut selection + editor.run_edit_command(&EditCommand::CutSelection); + assert_eq!(editor.get_buffer(), "world test"); + } + + #[test] + fn two_word_motions_then_delete() { + use crate::enums::{Movement, WordMotionTarget}; + let mut editor = editor_with("aaa bbb ccc ddd"); + editor.line_buffer.set_insertion_point(0); + // First w: select "aaa " + editor.run_edit_command(&EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }); + // Second w: restart and select "bbb " + editor.run_edit_command(&EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }); + let sel = editor.hx_selection().unwrap(); + let selected = &editor.get_buffer()[sel.range().0..sel.range().1]; + assert_eq!(selected, "bbb "); + // d: cut "bbb " + editor.run_edit_command(&EditCommand::CutSelection); + assert_eq!(editor.get_buffer(), "aaa ccc ddd"); + } + + #[test] + fn word_motion_with_count_2() { + use crate::enums::{Movement, WordMotionTarget}; + let mut editor = editor_with("aaa bbb ccc ddd"); + editor.line_buffer.set_insertion_point(0); + // 2w: skip two words at once + editor.run_edit_command(&EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 2, + }); + let sel = editor.hx_selection().unwrap(); + let selected = &editor.get_buffer()[sel.range().0..sel.range().1]; + // Each count iteration in word_right restarts the anchor at the + // new word boundary (Helix semantics), so 2w selects only the + // second word span, not both words from the origin. + assert_eq!(selected, "bbb "); + } + + // ── h/l motions through run_edit_command ──────────────────────── + + #[test] + fn h_motion_moves_left_and_restarts() { + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(3); + editor.hx_restart_selection(); + // Simulate Normal mode: MoveLeft then HxRestartSelection + editor.run_edit_command(&EditCommand::MoveLeft { select: false }); + editor.run_edit_command(&EditCommand::HxRestartSelection); + assert_eq!(editor.insertion_point(), 2); + let sel = editor.hx_selection().unwrap(); + // 1-wide selection at position 2 + assert_eq!(sel.anchor, 2); + assert_eq!(sel.head, 3); + } + + #[test] + fn l_motion_moves_right_and_restarts() { + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(1); + editor.hx_restart_selection(); + // Simulate Normal mode: MoveRight then HxRestartSelection + editor.run_edit_command(&EditCommand::MoveRight { select: false }); + editor.run_edit_command(&EditCommand::HxRestartSelection); + assert_eq!(editor.insertion_point(), 2); + let sel = editor.hx_selection().unwrap(); + assert_eq!(sel.anchor, 2); + assert_eq!(sel.head, 3); + } + + // ── f/t motions through run_edit_command ──────────────────────── + + #[test] + fn f_motion_extends_to_char() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + // Simulate Normal mode f w: MoveRightUntil + HxSyncCursorWithRestart + editor.run_edit_command(&EditCommand::MoveRightUntil { + c: 'w', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = editor.hx_selection().unwrap(); + // Forward selection from 0 to past 'w' (byte 6 + 1 = 7) + assert_eq!(sel.anchor, 0); + assert!(sel.head > 6, "head should be past 'w': head={}", sel.head); + } + + #[test] + fn t_motion_stops_before_char() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + // Simulate Normal mode t w: MoveRightBefore + HxSyncCursorWithRestart + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'w', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = editor.hx_selection().unwrap(); + // Should stop before 'w' (at the space, byte 5) + assert_eq!(sel.anchor, 0); + // Cursor should be before 'w' + let cursor = sel.cursor(editor.get_buffer()); + assert!(cursor < 6, "cursor should be before 'w': cursor={}", cursor); + } + + #[test] + fn t_motion_twice_preserves_selection() { + let mut editor = editor_with("hello world"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + // First t w — extending motion uses HxSyncCursorWithRestart + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'w', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + + let sel_after_first = *editor.hx_selection().unwrap(); + + // Second t w — should NOT collapse the selection + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'w', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + + let sel_after_second = *editor.hx_selection().unwrap(); + assert_eq!( + sel_after_first.anchor, sel_after_second.anchor, + "anchor should not change on repeat t: first={:?}, second={:?}", + sel_after_first, sel_after_second + ); + assert_eq!( + sel_after_first.head, sel_after_second.head, + "head should not change on repeat t: first={:?}, second={:?}", + sel_after_first, sel_after_second + ); + } + + #[test] + fn t_then_reverse_t_restarts_correctly() { + // "abcabc": cursor at 0, selection [a] = {anchor:0, head:1}. + // `ta` restarts: anchor = old cursor (0), extends to before + // second 'a' → {0, 3}, cursor on 'c' at 2. + // `Ta` restarts: anchor = old cursor (2), backward → anchor + // advances to 3 (direction flip), head = 1. + // Selection = {3, 1}, covering "bc" backward. + let mut editor = editor_with("abcabc"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + // Forward `ta` + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'a', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 3), "after ta"); + + // Backward `Ta` + editor.run_edit_command(&EditCommand::MoveLeftBefore { + c: 'a', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = *editor.hx_selection().unwrap(); + // Anchor restarts at old cursor (2), then flips to 3 because + // the motion went backward. Head = 1 (after 'a' at 0). + assert_eq!( + (sel.anchor, sel.head), + (3, 1), + "anchor should flip to 3, head at 1" + ); + } + + #[test] + fn t_motion_advances_to_next_occurrence() { + // "axbxc": cursor at 0, next grapheme is 'x'. + // Helix t x skips the immediate 'x' and stops before + // the second 'x' (byte 2, before byte 3). + let mut editor = editor_with("axbxc"); + editor.set_edit_mode(PromptEditMode::Helix(PromptHelixMode::Normal)); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + // t x from pos 0: skip immediate 'x' at 1, find 'x' at 3, + // stop before it → cursor at 2. + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'x', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = *editor.hx_selection().unwrap(); + // Selection from 0 to byte 3 (just past 'b') + assert_eq!(sel.anchor, 0); + assert_eq!(sel.head, 3); + + // Second t x: cursor is at 2 ('b'), next grapheme is 'x' at 3. + // Skip that 'x', no third 'x' → no movement → selection preserved. + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'x', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel2 = *editor.hx_selection().unwrap(); + assert_eq!(sel2.anchor, sel.anchor, "selection should be preserved"); + assert_eq!(sel2.head, sel.head, "selection should be preserved"); + } + + // ── sync_cursor_with_restart when no prior selection ───────── + + #[test] + fn sync_cursor_with_restart_creates_fresh_selection() { + // No prior hx_selection; should create a 1-wide selection at cursor. + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(2); + assert!(editor.hx_selection().is_none()); + + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = editor.hx_selection().unwrap(); + // Fresh restart creates a 1-wide selection: anchor=2, head=3. + assert_eq!(sel.anchor, 2); + assert_eq!(sel.head, 3); + } + + // ── InsertStyle::Before (i) selection tracking ────────────── + + #[test] + fn i_mode_shift_tracks_insertion() { + // Simulate: 'i' mode — type 'xy' before selection [w]orld + // The selection [w] (anchor=0, head=1) should shift to (2, 3). + let mut editor = editor_with("world"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + let sel0 = *editor.hx_selection().unwrap(); + assert_eq!((sel0.anchor, sel0.head), (0, 1)); + + // Move to selection start (i mode). + editor.run_edit_command(&EditCommand::HxMoveToSelectionStart); + + // Insert 'x', then shift. + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + let sel1 = *editor.hx_selection().unwrap(); + assert_eq!((sel1.anchor, sel1.head), (1, 2)); + + // Insert 'y', then shift. + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + let sel2 = *editor.hx_selection().unwrap(); + assert_eq!((sel2.anchor, sel2.head), (2, 3)); + + // Buffer should be "xyworld". + assert_eq!(editor.get_buffer(), "xyworld"); + } + + #[test] + fn i_mode_shift_tracks_backspace() { + // Simulate: 'i' mode — type 'xy' then backspace once. + // Start: "world", selection [w] at (0, 1). + let mut editor = editor_with("world"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + // Move to selection start (i mode), insert 'x', 'y'. + editor.run_edit_command(&EditCommand::HxMoveToSelectionStart); + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + // Selection is now (2, 3) covering 'w' in "xyworld". + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (2, 3)); + + // Backspace: deletes 'y', cursor goes from 2 to 1. + editor.run_edit_command(&EditCommand::Backspace); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (1, 2)); + assert_eq!(editor.get_buffer(), "xworld"); + } + + #[test] + fn a_mode_extend_tracks_backspace() { + // Start: "hello", selection [h] at (0, 1). + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + // Move to selection end (a mode), insert 'x', 'y'. + editor.run_edit_command(&EditCommand::HxMoveToSelectionEnd); + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + // Selection is (0, 3) covering "hxy" in "hxyello". + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 3)); + + // Backspace: deletes 'y', cursor from 3 to 2. + editor.run_edit_command(&EditCommand::Backspace); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (0, 2)); + assert_eq!(editor.get_buffer(), "hxello"); + } + + // ── InsertStyle::After (a) selection tracking ─────────────── + + #[test] + fn a_mode_extend_tracks_insertion() { + // Simulate: 'a' mode — type 'xy' after selection [h]ello + // The selection should extend from (0, 1) → (0, 2) → (0, 3). + let mut editor = editor_with("hello"); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + // Move to selection end (a mode). + editor.run_edit_command(&EditCommand::HxMoveToSelectionEnd); + + // Insert 'x', then extend. + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + let sel1 = *editor.hx_selection().unwrap(); + assert_eq!((sel1.anchor, sel1.head), (0, 2)); + + // Insert 'y', then extend. + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + let sel2 = *editor.hx_selection().unwrap(); + assert_eq!((sel2.anchor, sel2.head), (0, 3)); + + // Buffer should be "hxyello". + assert_eq!(editor.get_buffer(), "hxyello"); + } + + #[test] + fn a_mode_extend_normalizes_backward_selection() { + // Start with a backward selection: anchor=5, head=2. + // After 'a' mode extend, anchor should be normalized to min (2). + let mut editor = editor_with("hello world"); + editor.set_hx_selection(HxRange { anchor: 5, head: 2 }); + + // Move to selection end (range().1 = 5). + editor.run_edit_command(&EditCommand::HxMoveToSelectionEnd); + assert_eq!(editor.insertion_point(), 5); + + // Insert 'x', then extend. + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + let sel = *editor.hx_selection().unwrap(); + // anchor should be normalized to 2 (min), head at 6 (insertion point). + assert_eq!((sel.anchor, sel.head), (2, 6)); + } + } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 416e13fe6..2177b23bc 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -770,13 +770,40 @@ impl LineBuffer { self.insertion_point } - /// Moves the insertion point before the next char to the right - pub fn move_right_before(&mut self, c: char, current_line: bool) -> usize { + /// Moves the insertion point before the next char to the right. + /// + /// When `skip_current` is true and the very next grapheme is already + /// `c`, skip that occurrence and search for the next one — matching + /// Helix `t` semantics so that repeating the motion advances instead + /// of collapsing. When false, uses the standard find-and-step-back + /// behavior (Vi mode). + pub fn move_right_before(&mut self, c: char, current_line: bool, skip_current: bool) -> usize { + if skip_current { + let next_grapheme = self.grapheme_right_index(); + if self.lines[next_grapheme..].starts_with(c) { + let search_from = next_grapheme + c.len_utf8(); + let end = if current_line { + self.current_line_range().end + } else { + self.lines.len() + }; + if search_from < end { + if let Some(offset) = self.lines[search_from..end].find(c) { + self.insertion_point = search_from + offset; + self.insertion_point = self.grapheme_left_index(); + return self.insertion_point; + } + } + } + } + + // Fallthrough: either skip_current is false, or the next grapheme + // didn't match `c`, or no second occurrence was found. Use the + // standard search which finds the first `c` to the right. if let Some(index) = self.find_char_right(c, current_line) { self.insertion_point = index; self.insertion_point = self.grapheme_left_index(); } - self.insertion_point } @@ -789,12 +816,33 @@ impl LineBuffer { self.insertion_point } - /// Moves the insertion point before the next char to the left of offset - pub fn move_left_before(&mut self, c: char, current_line: bool) -> usize { + /// Moves the insertion point before the next char to the left of offset. + /// + /// When `skip_current` is true and the grapheme immediately to the + /// left is already `c`, skip that occurrence and search further left — + /// matching Helix `T` semantics so repeating the motion progresses. + /// When false, uses standard Vi behavior. + pub fn move_left_before(&mut self, c: char, current_line: bool, skip_current: bool) -> usize { + if skip_current { + let prev_grapheme = self.grapheme_left_index(); + if self.lines[prev_grapheme..].starts_with(c) { + let start = if current_line { + self.current_line_range().start + } else { + 0 + }; + if prev_grapheme > start { + if let Some(offset) = self.lines[start..prev_grapheme].rfind(c) { + self.insertion_point = start + offset + c.len_utf8(); + return self.insertion_point; + } + } + } + } + if let Some(index) = self.find_char_left(c, current_line) { self.insertion_point = index + c.len_utf8(); } - self.insertion_point } @@ -1522,7 +1570,7 @@ mod test { let mut line_buffer = buffer_with(input); line_buffer.set_insertion_point(position); - line_buffer.move_right_before(c, current_line); + line_buffer.move_right_before(c, current_line, false); assert_eq!(line_buffer.insertion_point(), expected); line_buffer.assert_valid(); @@ -1603,7 +1651,7 @@ mod test { let mut line_buffer = buffer_with(input); line_buffer.set_insertion_point(position); - line_buffer.move_left_before(c, current_line); + line_buffer.move_left_before(c, current_line, false); assert_eq!(line_buffer.insertion_point(), expected); line_buffer.assert_valid(); diff --git a/src/core_editor/mod.rs b/src/core_editor/mod.rs index b30009bb2..62af443d6 100644 --- a/src/core_editor/mod.rs +++ b/src/core_editor/mod.rs @@ -7,4 +7,6 @@ mod line_buffer; pub(crate) use clip_buffer::get_system_clipboard; pub(crate) use clip_buffer::{get_local_clipboard, Clipboard, ClipboardMode}; pub use editor::Editor; +#[cfg(feature = "hx")] +pub(crate) use editor::HxRange; pub use line_buffer::LineBuffer; diff --git a/src/edit_mode/cursors.rs b/src/edit_mode/cursors.rs index 67a1765a0..0ca08121d 100644 --- a/src/edit_mode/cursors.rs +++ b/src/edit_mode/cursors.rs @@ -1,7 +1,19 @@ +#[cfg(feature = "hx")] +use std::collections::HashMap; + use crossterm::cursor::SetCursorStyle; /// Maps cursor shapes to each edit mode (emacs, vi normal & vi insert). /// If any of the fields is `None`, the cursor won't get changed by Reedline for that mode. +/// +/// When the `hx` feature is enabled, the [`custom`](Self::custom) map +/// provides cursor shapes for Helix modes and any `PromptEditMode::Custom` +/// modes, keyed by the string representation of the mode name. +/// +/// The [`Default`] implementation leaves all fields as `None` (no cursor +/// changes). For Helix mode, use +/// [`CursorConfig::with_hx_defaults()`](Self::with_hx_defaults) which +/// pre-populates cursor shapes for Normal, Insert, and Select modes. #[derive(Default)] pub struct CursorConfig { /// The cursor to be used when in vi insert mode @@ -10,4 +22,101 @@ pub struct CursorConfig { pub vi_normal: Option, /// The cursor to be used when in emacs mode pub emacs: Option, + /// Cursor shapes for Helix and custom edit modes, keyed by mode name. + /// Helix modes use keys [`HX_CURSOR_NORMAL`], [`HX_CURSOR_INSERT`], + /// [`HX_CURSOR_SELECT`]. + #[cfg(feature = "hx")] + pub custom: HashMap, +} + +#[cfg(feature = "hx")] +impl CursorConfig { + /// Create a default config with Helix cursor shapes pre-populated. + pub fn with_hx_defaults() -> Self { + let mut custom = HashMap::new(); + custom.insert(HX_CURSOR_NORMAL.to_string(), SetCursorStyle::SteadyBlock); + custom.insert(HX_CURSOR_INSERT.to_string(), SetCursorStyle::SteadyBar); + custom.insert( + HX_CURSOR_SELECT.to_string(), + SetCursorStyle::SteadyUnderScore, + ); + + Self { + vi_insert: None, + vi_normal: None, + emacs: None, + custom, + } + } +} + +/// Cursor config key for Helix Normal mode. +#[cfg(feature = "hx")] +pub const HX_CURSOR_NORMAL: &str = "HX_NORMAL"; +/// Cursor config key for Helix Insert mode. +#[cfg(feature = "hx")] +pub const HX_CURSOR_INSERT: &str = "HX_INSERT"; +/// Cursor config key for Helix Select mode. +#[cfg(feature = "hx")] +pub const HX_CURSOR_SELECT: &str = "HX_SELECT"; + +/// Methods available when the `hx` feature is enabled. +#[cfg(feature = "hx")] +impl CursorConfig { + /// Register a cursor shape for a custom edit mode name. + /// + /// Helix modes use the keys [`HX_CURSOR_NORMAL`], [`HX_CURSOR_INSERT`], + /// and [`HX_CURSOR_SELECT`] (populated by + /// [`with_hx_defaults`](Self::with_hx_defaults)). + pub fn with_custom_cursor(mut self, mode_name: &str, style: SetCursorStyle) -> Self { + self.custom.insert(mode_name.to_string(), style); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_vi_cursors_are_none() { + let config = CursorConfig::default(); + assert!(config.vi_insert.is_none()); + assert!(config.vi_normal.is_none()); + assert!(config.emacs.is_none()); + } + + #[cfg(feature = "hx")] + #[test] + fn hx_defaults_are_set() { + let config = CursorConfig::with_hx_defaults(); + assert_eq!( + config.custom.get(HX_CURSOR_NORMAL), + Some(&SetCursorStyle::SteadyBlock) + ); + assert_eq!( + config.custom.get(HX_CURSOR_INSERT), + Some(&SetCursorStyle::SteadyBar) + ); + assert_eq!( + config.custom.get(HX_CURSOR_SELECT), + Some(&SetCursorStyle::SteadyUnderScore) + ); + } + + #[cfg(feature = "hx")] + #[test] + fn hx_builders_override_defaults() { + let config = CursorConfig::with_hx_defaults() + .with_custom_cursor(HX_CURSOR_NORMAL, SetCursorStyle::BlinkingBlock) + .with_custom_cursor(HX_CURSOR_SELECT, SetCursorStyle::BlinkingBar); + assert_eq!( + config.custom.get(HX_CURSOR_NORMAL), + Some(&SetCursorStyle::BlinkingBlock) + ); + assert_eq!( + config.custom.get(HX_CURSOR_SELECT), + Some(&SetCursorStyle::BlinkingBar) + ); + } } diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs new file mode 100644 index 000000000..9c4c45fc3 --- /dev/null +++ b/src/edit_mode/hx/mod.rs @@ -0,0 +1,1941 @@ +pub(crate) mod word; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +use crate::{ + edit_mode::EditMode, + enums::{Movement, ReedlineEvent, ReedlineRawEvent, WordMotionTarget}, + EditCommand, PromptEditMode, PromptHelixMode, +}; + +/// Helix-style editor modes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HelixMode { + /// Insert mode -- typing inserts text. + Insert, + /// Normal (command) mode -- keys are motions/actions. + #[default] + Normal, + /// Visual selection mode -- motions extend the selection. + Select, +} + +/// Pending state for multi-key sequences (g_, f/t/F/T + char). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Pending { + #[default] + None, + /// Waiting for second key after 'g' (gg, gh, gl). + Goto, + /// Waiting for target char after 'f'. + FindForward, + /// Waiting for target char after 't'. + TilForward, + /// Waiting for target char after 'F'. + FindBackward, + /// Waiting for target char after 'T'. + TilBackward, + /// Waiting for target char after 'r' (replace). + Replace, +} + +/// How the Helix selection should be adjusted as the user types in Insert mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum InsertStyle { + /// No selection tracking (entered via `I`, `A`, `c`, or other commands + /// that clear the selection before entering Insert mode). + #[default] + Plain, + /// Entered via `i`: text is inserted *before* the selection, so both + /// anchor and head must be shifted forward by the inserted byte length. + Before, + /// Entered via `a`: text is inserted *after* the selection, so the + /// selection head extends to track the cursor. + After, +} + +/// Helix-inspired edit mode for reedline. +/// +/// Supports three modes (Insert / Normal / Select) with word-granularity +/// motions and Helix-style selection semantics. +#[derive(Default)] +pub struct Helix { + mode: HelixMode, + pending: Pending, + /// Accumulated numeric prefix (0 = none entered). + count: usize, + /// How each `InsertChar` should adjust the Helix selection. + /// Set when entering Insert mode, reset to `Plain` on Esc. + insert_style: InsertStyle, +} + +impl Helix { + /// Wrap motion commands for Select mode: execute motion then apply + /// Helix `put_cursor` semantics to extend the selection. + /// + /// The sequence is: + /// 1. ...cmds — the motion (MoveRight, MoveLeft, etc.) starting + /// from the current `cursor()` display position + /// 2. HxSyncCursor — implements `put_cursor(extend=true)`: + /// adjusts anchor on direction flip, sets head with 1-width + /// block-cursor semantics, then sets insertion point for display + fn hx_extend(mut cmds: Vec) -> ReedlineEvent { + cmds.push(EditCommand::HxSyncCursor); + ReedlineEvent::Edit(cmds) + } + + /// Dispatch an extending motion (f/t/F/T) based on the current mode. + /// + /// Matches Helix's `Range::point(cursor).put_cursor(pos, true)` for + /// Normal mode: the anchor restarts at the old cursor then extends to + /// the target, with direction-flip adjustment when going backward. + /// Select mode simply extends the existing selection. + fn extending_motion_event(&self, cmds: Vec) -> ReedlineEvent { + if self.mode == HelixMode::Select { + return Self::hx_extend(cmds); + } + let mut v = cmds; + v.push(EditCommand::HxSyncCursorWithRestart); + ReedlineEvent::Edit(v) + } + + /// Dispatch a motion event based on the current mode. + /// - Normal => motion + collapse at new position (1-wide block cursor) + /// - Select => motion + extend, anchor stays + fn motion_event(&self, cmds: Vec) -> ReedlineEvent { + if self.mode == HelixMode::Select { + return Self::hx_extend(cmds); + } + let mut v = cmds; + v.push(EditCommand::HxRestartSelection); + ReedlineEvent::Edit(v) + } + + /// Switch to Insert mode and execute `pre_cmds` before returning Repaint. + fn enter_insert(&mut self, pre_cmds: Vec) -> ReedlineEvent { + self.mode = HelixMode::Insert; + let mut events = pre_cmds; + events.push(ReedlineEvent::Repaint); + ReedlineEvent::Multiple(events) + } + + /// Consume the accumulated count prefix, returning at least 1. + /// Capped at 100_000 to prevent OOM from absurdly large counts. + fn take_count(&mut self) -> usize { + let c = self.count.clamp(1, 100_000); + self.count = 0; + c + } + + /// Dispatch a word motion event. Word motions handle their own selection + /// internally (anchor adjustment, block-cursor semantics), so they do NOT + /// get the HxSyncCursor wrapper that simple cursor motions need. + /// + /// The restart for Normal mode is handled inside `hx_word_motion` so + /// that no-progress motions (e.g. at end-of-buffer) can preserve the + /// existing selection instead of collapsing it. + fn word_motion_event(&self, target: WordMotionTarget, count: usize) -> ReedlineEvent { + let movement = if self.mode == HelixMode::Select { + Movement::Extend + } else { + Movement::Move + }; + ReedlineEvent::Edit(vec![EditCommand::HxWordMotion { + target, + movement, + count, + }]) + } + + /// Resolve a pending 'g' prefix: gg, ge, gh, gl. + fn parse_goto(&mut self, code: KeyCode) -> ReedlineEvent { + self.pending = Pending::None; + // Count is intentionally discarded — goto motions are absolute positions. + self.count = 0; + match code { + KeyCode::Char('g') => { + self.motion_event(vec![EditCommand::MoveToStart { select: false }]) + } + // ge/gE not yet implemented (needs PrevWordEnd motion target). + // Fallthrough to None. + KeyCode::Char('h') => { + self.motion_event(vec![EditCommand::MoveToLineStart { select: false }]) + } + KeyCode::Char('l') => { + self.motion_event(vec![EditCommand::MoveToLineEnd { select: false }]) + } + _ => ReedlineEvent::None, + } + } + + /// Resolve a pending find/til char motion (f/t/F/T + char). + /// These are extending motions: the selection grows from the current + /// cursor to the target character. + /// + /// Resolve a pending find/til char motion (f/t/F/T + char). + /// + /// Normal mode: first iteration restarts anchor at old cursor then + /// extends to target (HxSyncCursorWithRestart). Subsequent iterations + /// only extend (HxSyncCursor) so the anchor stays fixed. + /// Select mode: all iterations extend (HxSyncCursor). + fn parse_find_char(&mut self, pending: Pending, c: char) -> ReedlineEvent { + self.pending = Pending::None; + let count = self.take_count(); + let cmd = match pending { + Pending::FindForward => EditCommand::MoveRightUntil { c, select: false }, + Pending::TilForward => EditCommand::MoveRightBefore { c, select: false }, + Pending::FindBackward => EditCommand::MoveLeftUntil { c, select: false }, + Pending::TilBackward => EditCommand::MoveLeftBefore { c, select: false }, + _ => unreachable!(), + }; + if count <= 1 { + self.extending_motion_event(vec![cmd]) + } else { + let first = self.extending_motion_event(vec![cmd.clone()]); + let rest = Self::hx_extend(vec![cmd]); + let mut events = vec![first]; + events.resize(count, rest); + ReedlineEvent::Multiple(events) + } + } + + /// Resolve a pending 'r' + char (replace every grapheme in selection). + fn parse_replace_char(&mut self, c: char) -> ReedlineEvent { + self.pending = Pending::None; + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxReplaceSelectionWithChar(c), + ]) + } + + /// Handle key events in Normal or Select mode. + /// + /// All motion keys go through self.motion_event() which wraps them with + /// the appropriate selection restart/extend commands based on the mode. + /// All inner MoveLeft/MoveRight use `select: false` since selection is + /// managed through HxRestartSelection/HxSyncCursor. + fn parse_normal_select(&mut self, code: KeyCode, modifiers: KeyModifiers) -> ReedlineEvent { + // ── Resolve pending multi-key sequences ───────────────────────── + match self.pending { + Pending::Goto => return self.parse_goto(code), + Pending::FindForward + | Pending::TilForward + | Pending::FindBackward + | Pending::TilBackward => { + let p = self.pending; + if let KeyCode::Char(c) = code { + return self.parse_find_char(p, c); + } + self.pending = Pending::None; + return ReedlineEvent::None; + } + Pending::Replace => { + if let KeyCode::Char(c) = code { + return self.parse_replace_char(c); + } + self.pending = Pending::None; + return ReedlineEvent::None; + } + Pending::None => {} + } + + // ── Count prefix accumulation ───────────────────────────────── + match code { + KeyCode::Char(c @ '1'..='9') => { + self.count = self + .count + .saturating_mul(10) + .saturating_add((c as usize) - ('0' as usize)); + return ReedlineEvent::None; + } + KeyCode::Char('0') if self.count > 0 => { + self.count = self.count.saturating_mul(10); + return ReedlineEvent::None; + } + _ => {} + } + + match code { + // ── Pending keys: don't consume count, just set pending state ── + KeyCode::Char('g') => { + self.pending = Pending::Goto; + return ReedlineEvent::None; + } + KeyCode::Char('f') => { + self.pending = Pending::FindForward; + return ReedlineEvent::None; + } + KeyCode::Char('t') => { + self.pending = Pending::TilForward; + return ReedlineEvent::None; + } + KeyCode::Char('F') => { + self.pending = Pending::FindBackward; + return ReedlineEvent::None; + } + KeyCode::Char('T') => { + self.pending = Pending::TilBackward; + return ReedlineEvent::None; + } + KeyCode::Char('r') => { + self.pending = Pending::Replace; + return ReedlineEvent::None; + } + _ => {} + } + + // Try counted motion keys first, then fall through to mode switches + // and selection/editing commands which don't use count. + if let Some(event) = self.parse_motion(code) { + return event; + } + + // Everything below ignores count — reset so it doesn't leak. + self.count = 0; + + match code { + // ── Mode switches ───────────────────────────────────────── + KeyCode::Char('i') => { + self.insert_style = InsertStyle::Before; + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + ])]) + } + KeyCode::Char('a') => { + self.insert_style = InsertStyle::After; + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + ])]) + } + KeyCode::Char('I') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineStart { select: false }, + ])]), + KeyCode::Char('A') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineEnd { select: false }, + ])]), + KeyCode::Char('v') => match self.mode { + HelixMode::Normal => { + self.mode = HelixMode::Select; + ReedlineEvent::Repaint + } + HelixMode::Select => { + self.mode = HelixMode::Normal; + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + } + HelixMode::Insert => ReedlineEvent::None, + }, + KeyCode::Char(';') => ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + _ => self.parse_non_counted(code, modifiers), + } + } + + /// Handle counted motion keys (h/l/j/k/w/b/e/W/B/E/Home/End). + /// + /// Returns `Some(event)` if the key is a motion, `None` otherwise so + /// the caller can fall through to mode-switch and editing commands. + fn parse_motion(&mut self, code: KeyCode) -> Option { + let event = match code { + // ── Basic motions (h/l/j/k) ─────────────────────────── + KeyCode::Char('h') => { + let count = self.take_count(); + if self.mode == HelixMode::Select { + let motion = Self::hx_extend(vec![EditCommand::MoveLeft { select: false }]); + if count <= 1 { + motion + } else { + ReedlineEvent::Multiple(vec![motion; count]) + } + } else { + let mut cmds: Vec = + vec![EditCommand::MoveLeft { select: false }; count]; + cmds.push(EditCommand::HxRestartSelection); + ReedlineEvent::Edit(cmds) + } + } + KeyCode::Char('l') => { + let count = self.take_count(); + let motion = ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + self.motion_event(vec![EditCommand::MoveRight { select: false }]), + ]); + if count <= 1 { + motion + } else { + ReedlineEvent::Multiple(vec![motion; count]) + } + } + KeyCode::Char('j') => { + let count = self.take_count(); + let motion = + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuDown, ReedlineEvent::Down]); + if count <= 1 { + motion + } else { + ReedlineEvent::Multiple(vec![motion; count]) + } + } + KeyCode::Char('k') => { + let count = self.take_count(); + let motion = + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuUp, ReedlineEvent::Up]); + if count <= 1 { + motion + } else { + ReedlineEvent::Multiple(vec![motion; count]) + } + } + + // ── Word motions ────────────────────────────────────── + KeyCode::Char('w') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::NextWordStart, count) + } + KeyCode::Char('b') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::PrevWordStart, count) + } + KeyCode::Char('e') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::NextWordEnd, count) + } + KeyCode::Char('W') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::NextLongWordStart, count) + } + KeyCode::Char('B') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::PrevLongWordStart, count) + } + KeyCode::Char('E') => { + let count = self.take_count(); + self.word_motion_event(WordMotionTarget::NextLongWordEnd, count) + } + + // ── Line motions ────────────────────────────────────── + // Helix uses gh/gl for line start/end (in the goto menu). + // Home/End keys are bound directly as convenience. + KeyCode::Home => { + self.count = 0; + self.motion_event(vec![EditCommand::MoveToLineStart { select: false }]) + } + KeyCode::End => { + self.count = 0; + self.motion_event(vec![EditCommand::MoveToLineEnd { select: false }]) + } + + _ => return None, + }; + Some(event) + } + + /// Handle commands that operate on the selection (no count prefix). + fn parse_non_counted(&mut self, code: KeyCode, modifiers: KeyModifiers) -> ReedlineEvent { + match code { + KeyCode::Char('%') => ReedlineEvent::Edit(vec![ + EditCommand::MoveToStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToEnd { select: false }, + EditCommand::HxSyncCursor, + ]), + KeyCode::Char('x') | KeyCode::Char('X') => { + // Select entire line. In multi-line Helix, x extends down and + // X extends up; for a single-line editor both select the whole line. + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToLineEnd { select: false }, + EditCommand::HxSyncCursor, + ]) + } + + // ── Editing actions ────────────────────────────────────── + // Alt+d: delete without yanking (Helix default) + KeyCode::Char('d') if modifiers == KeyModifiers::ALT => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxDeleteSelection, + EditCommand::HxRestartSelection, + ]), + KeyCode::Char('d') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CutSelection, + EditCommand::HxRestartSelection, + ]), + // Alt+c: change without yanking (Helix default) + KeyCode::Char('c') if modifiers == KeyModifiers::ALT => { + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxDeleteSelection, + EditCommand::HxClearSelection, + ])]) + } + KeyCode::Char('c') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CutSelection, + EditCommand::HxClearSelection, + ])]), + KeyCode::Char('y') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CopySelection, + ]), + KeyCode::Char('p') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + EditCommand::HxClearSelection, + EditCommand::PasteCutBufferBefore, + EditCommand::HxRestartSelection, + ]), + KeyCode::Char('P') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + EditCommand::HxClearSelection, + EditCommand::PasteCutBufferBefore, + EditCommand::HxRestartSelection, + ]), + KeyCode::Char('R') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxDeleteSelection, + EditCommand::PasteCutBufferBefore, + EditCommand::HxRestartSelection, + ]), + KeyCode::Char('u') => { + ReedlineEvent::Edit(vec![EditCommand::Undo, EditCommand::HxRestartSelection]) + } + KeyCode::Char('U') => { + ReedlineEvent::Edit(vec![EditCommand::Redo, EditCommand::HxRestartSelection]) + } + KeyCode::Char('~') => ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxSwitchCaseSelection, + ]), + KeyCode::Char('o') => ReedlineEvent::Edit(vec![EditCommand::HxFlipSelection]), + KeyCode::Enter => ReedlineEvent::Enter, + _ => ReedlineEvent::None, + } + } +} + +impl EditMode for Helix { + /// Parse a raw crossterm event into a ReedlineEvent. + /// + /// Structure: + /// 1. Extract key event (non-key events => None) + /// 2. Global bindings: Ctrl+C => CtrlC, Ctrl+D => CtrlD + /// 3. Insert mode: Esc => Normal + [Esc, Repaint]; Char(c) => InsertChar; + /// Enter/Backspace/Delete as expected + /// 4. Normal/Select mode: Esc => Normal + [Esc, Repaint]; else delegate + /// to parse_normal_select + fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { + let Event::Key(KeyEvent { + code, modifiers, .. + }) = event.into() + else { + return ReedlineEvent::None; + }; + + // ── Global bindings ─────────────────────────────────────────── + // Ctrl+C is always interrupt. Ctrl+D is EOF in Normal/Select but + // delete-forward in Insert (handled below). + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('c') { + return ReedlineEvent::CtrlC; + } + + match self.mode { + // ── Insert mode ─────────────────────────────────────────── + HelixMode::Insert => match (code, modifiers) { + (KeyCode::Esc, _) => { + self.mode = HelixMode::Normal; + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + // Step back so the block cursor lands ON the last typed character + // (Insert cursor is between chars; Normal cursor is on a char). + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + ReedlineEvent::Edit(vec![EditCommand::Delete]) + } + (KeyCode::Char(c), _) => match self.insert_style { + InsertStyle::Before => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + InsertStyle::After => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), + }, + (KeyCode::Enter, _) => ReedlineEvent::Enter, + (KeyCode::Backspace, _) => match self.insert_style { + InsertStyle::Before => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + InsertStyle::After => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::Backspace]), + }, + (KeyCode::Delete, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![EditCommand::Delete, EditCommand::HxClearSelection]) + } + // Arrow keys / Home / End move the cursor away from the + // insert position — clear the selection since byte offsets + // become meaningless after arbitrary cursor movement. + (KeyCode::Left, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Right, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveRight { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Home, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::End, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineEnd { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Up, _) => ReedlineEvent::Up, + (KeyCode::Down, _) => ReedlineEvent::Down, + (KeyCode::Tab, _) => ReedlineEvent::None, + _ => ReedlineEvent::None, + }, + + // ── Normal / Select mode ────────────────────────────────── + HelixMode::Normal | HelixMode::Select => { + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('d') { + return ReedlineEvent::CtrlD; + } + match code { + KeyCode::Esc => { + self.mode = HelixMode::Normal; + self.pending = Pending::None; + self.count = 0; + // Collapse selection so stale extended selection doesn't persist. + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + } + _ => self.parse_normal_select(code, modifiers), + } + } + } + } + + /// Return the current prompt edit mode indicator. + fn edit_mode(&self) -> PromptEditMode { + match self.mode { + HelixMode::Insert => PromptEditMode::Helix(PromptHelixMode::Insert), + HelixMode::Normal => PromptEditMode::Helix(PromptHelixMode::Normal), + HelixMode::Select => PromptEditMode::Helix(PromptHelixMode::Select), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core_editor::{Editor, HxRange}; + use crate::enums::{Movement, UndoBehavior, WordMotionTarget}; + use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState}; + + fn key_press(code: KeyCode, modifiers: KeyModifiers) -> ReedlineRawEvent { + Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + .try_into() + .unwrap() + } + + fn char_key(c: char) -> ReedlineRawEvent { + key_press(KeyCode::Char(c), KeyModifiers::NONE) + } + + fn edit_mode_hx(hx: &Helix) -> PromptHelixMode { + match hx.edit_mode() { + PromptEditMode::Helix(m) => m, + other => panic!("unexpected prompt edit mode: {:?}", other), + } + } + + // ── parse_event unit tests ────────────────────────────────────────── + + #[test] + fn ctrl_c_works_in_all_modes() { + for initial_mode in [HelixMode::Insert, HelixMode::Normal, HelixMode::Select] { + let mut hx = Helix { + mode: initial_mode, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(event, ReedlineEvent::CtrlC); + } + } + + #[test] + fn ctrl_d_eof_in_normal_and_select() { + for initial_mode in [HelixMode::Normal, HelixMode::Select] { + let mut hx = Helix { + mode: initial_mode, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(event, ReedlineEvent::CtrlD); + } + } + + #[test] + fn ctrl_d_deletes_forward_in_insert() { + let mut hx = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Delete])); + } + + #[test] + fn esc_from_insert_enters_normal() { + let mut hx = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Normal); + } + + #[test] + fn i_from_normal_enters_insert() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('i')); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Insert); + assert_eq!(hx.insert_style, InsertStyle::Before); + } + + #[test] + fn a_from_normal_enters_insert_after_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('a')); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Insert); + assert_eq!(hx.insert_style, InsertStyle::After); + } + + #[test] + fn v_toggles_select_mode() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + // Normal -> Select + let event = hx.parse_event(char_key('v')); + assert_eq!(event, ReedlineEvent::Repaint); + assert_eq!(hx.mode, HelixMode::Select); + + // Select -> Normal (with selection restart) + let event = hx.parse_event(char_key('v')); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert_eq!(hx.mode, HelixMode::Normal); + } + + #[test] + fn insert_char_in_insert_mode() { + let mut hx = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + let event = hx.parse_event(char_key('x')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::InsertChar('x')]) + ); + } + + #[test] + fn insert_char_in_append_mode_extends_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + ..Default::default() + }; + let event = hx.parse_event(char_key('x')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::InsertChar('x'), + EditCommand::HxExtendSelectionToInsertionPoint, + ]) + ); + } + + #[test] + fn insert_char_in_before_mode_shifts_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Before, + ..Default::default() + }; + let event = hx.parse_event(char_key('x')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::InsertChar('x'), + EditCommand::HxShiftSelectionToInsertionPoint, + ]) + ); + } + + #[test] + fn h_in_normal_produces_motion_with_collapse() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('h')); + // Normal mode: move then collapse (no visible selection). + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn h_in_select_extends_without_restart() { + let mut hx = Helix { + mode: HelixMode::Select, + ..Default::default() + }; + let event = hx.parse_event(char_key('h')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxSyncCursor, + ]) + ); + } + + #[test] + fn w_in_normal_produces_word_motion() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('w')); + // Word motions handle their own selection (restart is internal to + // hx_word_motion so no-progress motions can preserve the selection). + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }]) + ); + } + + #[test] + fn enter_in_insert_mode() { + let mut hx = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(event, ReedlineEvent::Enter); + } + + #[test] + fn backspace_plain_insert_is_bare() { + let mut hx = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Backspace])); + assert_eq!(hx.insert_style, InsertStyle::Plain); + } + + #[test] + fn backspace_in_a_mode_extends_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxExtendSelectionToInsertionPoint, + ]) + ); + // insert_style stays After — selection is still tracked. + assert_eq!(hx.insert_style, InsertStyle::After); + } + + #[test] + fn backspace_in_i_mode_shifts_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Before, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxShiftSelectionToInsertionPoint, + ]) + ); + assert_eq!(hx.insert_style, InsertStyle::Before); + } + + #[test] + fn delete_in_insert_clears_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Before, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::Delete, EditCommand::HxClearSelection]) + ); + assert_eq!(hx.insert_style, InsertStyle::Plain); + } + + #[test] + fn arrow_left_in_insert_clears_selection() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxClearSelection, + ]) + ); + assert_eq!(hx.insert_style, InsertStyle::Plain); + } + + #[test] + fn count_not_consumed_by_editing_commands() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + // Press '3' then 'd' — count should be discarded, not affect deletion. + hx.parse_event(char_key('3')); + assert_eq!(hx.count, 3); + let event = hx.parse_event(char_key('d')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CutSelection, + EditCommand::HxRestartSelection, + ]) + ); + // Count was reset. + assert_eq!(hx.count, 0); + } + + #[test] + fn count_applies_to_j_k() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('3')); + let event = hx.parse_event(char_key('j')); + assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 3)); + } + + #[test] + fn edit_mode_returns_correct_prompt() { + let mut hx = Helix::default(); + assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Normal)); + + hx.mode = HelixMode::Insert; + assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Insert)); + + hx.mode = HelixMode::Select; + assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Select)); + } + + // ── Word motion integration tests ─────────────────────────────────── + // + // Selection notation (gap / right-exclusive convention): + // [text] -- forward selection: `[` = anchor, `]` = head + // ]text[ -- backward selection: `]` = head, `[` = anchor + // [h]ello -- 1-wide selection (anchor=0, head=1) + // + // Helix uses CharClass-based word boundaries (Word/Punctuation/Whitespace), + // so "foo.bar" is three words: "foo", ".", "bar". + // WORD motions (W/B/E) split only on whitespace. + + /// Parse selection notation into (buffer, anchor, head). + /// + /// `[` marks the **anchor** position and `]` marks the **head** position + /// (byte offsets into the returned buffer). The order they appear in the + /// string determines selection direction: + /// + /// - Forward `[hello ]world` → anchor=0, head=6 + /// - Backward `]hello[ world` → anchor=5, head=0 + /// - 1-wide `[h]ello world` → anchor=0, head=1 + /// + /// Handles multi-byte UTF-8 content correctly. + fn parse_selection(s: &str) -> (String, usize, usize) { + let mut buf = String::new(); + let mut anchor_pos: Option = None; + let mut head_pos: Option = None; + + for ch in s.chars() { + match ch { + '[' => { + assert!(anchor_pos.is_none(), "duplicate `[` in notation"); + anchor_pos = Some(buf.len()); + } + ']' => { + assert!(head_pos.is_none(), "duplicate `]` in notation"); + head_pos = Some(buf.len()); + } + _ => buf.push(ch), + } + } + + let anchor = anchor_pos.expect("missing `[` in notation"); + let head = head_pos.expect("missing `]` in notation"); + (buf, anchor, head) + } + + fn editor_with(buffer: &str, cursor: usize) -> Editor { + let mut editor = Editor::default(); + editor.set_buffer(buffer.to_string(), UndoBehavior::CreateUndoPoint); + editor.run_edit_command(&EditCommand::MoveToPosition { + position: cursor, + select: false, + }); + editor + } + + fn run_commands(editor: &mut Editor, commands: &[EditCommand]) { + for cmd in commands { + editor.run_edit_command(cmd); + } + } + + /// Run a normal-mode motion from `input` notation and assert `expected` notation. + fn assert_sel(commands: &[EditCommand], input: &str, expected: &str) { + let (buf, _in_anchor, in_head) = parse_selection(input); + let (exp_buf, exp_anchor, exp_head) = parse_selection(expected); + assert_eq!(buf, exp_buf, "input/expected buffer mismatch"); + + // Set up editor with cursor at the visual cursor position (inclusive). + let in_cursor = HxRange { + anchor: _in_anchor, + head: in_head, + } + .cursor(&buf); + + // Word motions handle their own selection internally (restart + // for Move mode is inside hx_word_motion). + let mut editor = editor_with(&buf, in_cursor); + run_commands(&mut editor, commands); + + let sel = editor + .hx_selection() + .expect("expected hx_selection to be set"); + + assert_eq!( + (sel.anchor, sel.head), + (exp_anchor, exp_head), + "\n input: {}\n expected: {}\n got: anchor={} head={}", + input, + expected, + sel.anchor, + sel.head, + ); + } + + // ── w (word right start) ──────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_w() { + let w = [EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&w, "[h]ello world", "[hello ]world"); + assert_sel(&w, "h[e]llo world", "h[ello ]world"); + assert_sel(&w, "[h]ello world", "[hello ]world"); + assert_sel(&w, "[h]ello .world", "[hello ].world"); + assert_sel(&w, "[h]ello.world", "[hello].world"); + assert_sel(&w, "hello[.]world", "hello.[world]"); + assert_sel(&w, "[h]ello_world test", "[hello_world ]test"); + assert_sel(&w, "test [.].. next", "test [... ]next"); + } + + // ── b (word left) ─────────────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_b() { + let b = [EditCommand::HxWordMotion { + target: WordMotionTarget::PrevWordStart, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&b, "hello worl[d]", "hello ]world["); + assert_sel(&b, "hello [w]orld", "]hello [world"); + assert_sel(&b, "hello[.]world", "]hello[.world"); + assert_sel(&b, "hello_worl[d]", "]hello_world["); + assert_sel(&b, "test ...[n]ext", "test ]...[next"); + } + + // ── e (word right end) ────────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_e() { + let e = [EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordEnd, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&e, "[h]ello world", "[hello] world"); + assert_sel(&e, "hell[o] world", "hello[ world]"); + assert_sel(&e, "[h]ello.world", "[hello].world"); + assert_sel(&e, "hello[.]world", "hello.[world]"); + assert_sel(&e, "[h]ello_world test", "[hello_world] test"); + assert_sel(&e, "[t]est... next", "[test]... next"); + assert_sel(&e, "test[...] next", "test...[ next]"); + } + + // ── W (WORD right start) ──────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_big_w() { + let w = [EditCommand::HxWordMotion { + target: WordMotionTarget::NextLongWordStart, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&w, "[h]ello.world test", "[hello.world ]test"); + assert_sel(&w, "[h]ello world", "[hello ]world"); + assert_sel(&w, "[h]ello.world", "[hello.world]"); + assert_sel(&w, "hello.world [t]est", "hello.world [test]"); + assert_sel(&w, "[h]ello_world.test next", "[hello_world.test ]next"); + assert_sel(&w, "[t]est... next", "[test... ]next"); + } + + // ── B (WORD left) ─────────────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_big_b() { + let b = [EditCommand::HxWordMotion { + target: WordMotionTarget::PrevLongWordStart, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&b, "hello.world tes[t]", "hello.world ]test["); + assert_sel(&b, "hello.world [t]est", "]hello.world [test"); + assert_sel(&b, "hello.worl[d]", "]hello.world["); + assert_sel(&b, "test...nex[t]", "]test...next["); + } + + // ── E (WORD right end) ────────────────────────────────────────────── + // Test cases from schlich/helix-mode prototype. + + #[test] + fn test_big_e() { + let e = [EditCommand::HxWordMotion { + target: WordMotionTarget::NextLongWordEnd, + movement: Movement::Move, + count: 1, + }]; + + assert_sel(&e, "[h]ello.world test", "[hello.world] test"); + assert_sel(&e, "[hello.world] test", "hello.world[ test]"); + assert_sel(&e, "[h]ello world", "[hello] world"); + assert_sel(&e, "[t]est...next more", "[test...next] more"); + } + + // ── parse_selection unit tests ────────────────────────────────────── + + #[test] + fn test_parse_selection_forward() { + let (buf, anchor, head) = parse_selection("[hello] world"); + assert_eq!(buf, "hello world"); + assert_eq!(anchor, 0); + assert_eq!(head, 5); + } + + #[test] + fn test_parse_selection_backward() { + let (buf, anchor, head) = parse_selection("]hello[ world"); + assert_eq!(buf, "hello world"); + assert_eq!(anchor, 5); + assert_eq!(head, 0); + } + + #[test] + fn test_parse_selection_one_wide() { + let (buf, anchor, head) = parse_selection("[h]ello world"); + assert_eq!(buf, "hello world"); + assert_eq!(anchor, 0); + assert_eq!(head, 1); + } + + #[test] + fn test_parse_selection_mid_buffer() { + let (buf, anchor, head) = parse_selection("hello [world]"); + assert_eq!(buf, "hello world"); + assert_eq!(anchor, 6); + assert_eq!(head, 11); + } + + #[test] + fn test_parse_selection_utf8() { + let (buf, anchor, head) = parse_selection("[café] world"); + assert_eq!(buf, "café world"); + assert_eq!(anchor, 0); + assert_eq!(head, 5); // 'é' is 2 bytes: c(1) a(1) f(1) é(2) = 5 + } + + // ── Count prefix tests ──────────────────────────────────────────── + + #[test] + fn count_prefix_repeats_h_motion_normal() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + // Press '3' then 'h' — Normal mode batches moves + one restart. + let event = hx.parse_event(char_key('3')); + assert_eq!(event, ReedlineEvent::None); + let event = hx.parse_event(char_key('h')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveLeft { select: false }, + EditCommand::MoveLeft { select: false }, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn count_prefix_repeats_h_motion_select() { + let mut hx = Helix { + mode: HelixMode::Select, + ..Default::default() + }; + // Press '3' then 'h' — Select mode needs per-step sync. + hx.parse_event(char_key('3')); + let event = hx.parse_event(char_key('h')); + assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 3)); + } + + #[test] + fn count_prefix_passes_to_word_motion() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('2')); + let event = hx.parse_event(char_key('w')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 2, + }]) + ); + } + + #[test] + fn count_zero_extends_digit() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('1')); + hx.parse_event(char_key('0')); + let event = hx.parse_event(char_key('l')); + // Should produce 10 repetitions + assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 10)); + } + + // ── Pending state tests ─────────────────────────────────────────── + + #[test] + fn invalid_key_after_goto_cancels() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('g')); + let event = hx.parse_event(char_key('z')); // invalid goto target + assert_eq!(event, ReedlineEvent::None); + assert_eq!(hx.pending, Pending::None); + } + + #[test] + fn invalid_key_after_find_cancels() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('f')); + // Esc is handled by the top-level Normal/Select match before pending + // resolution, so it resets everything (mode, pending, count). + let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert_eq!(hx.pending, Pending::None); + } + + #[test] + fn goto_gg_moves_to_start() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('g')); + let event = hx.parse_event(char_key('g')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveToStart { select: false }, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn goto_ge_is_unbound() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('g')); + let event = hx.parse_event(char_key('e')); + // ge is not yet implemented (needs PrevWordEnd motion target). + assert_eq!(event, ReedlineEvent::None); + } + + #[test] + fn f_char_produces_extending_motion() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('f')); + let event = hx.parse_event(char_key('x')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveRightUntil { + c: 'x', + select: false + }, + EditCommand::HxSyncCursorWithRestart, + ]) + ); + } + + // ── I/A mode switch tests ───────────────────────────────────────── + + #[test] + fn big_i_enters_insert_at_line_start() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('I')); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineStart { select: false }, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Insert); + } + + #[test] + fn big_a_enters_insert_at_line_end() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('A')); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineEnd { select: false }, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Insert); + } + + // ── Edit command tests ──────────────────────────────────────────── + + #[test] + fn d_deletes_with_yank() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('d')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CutSelection, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn alt_d_deletes_without_yank() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::ALT)); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxDeleteSelection, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn c_changes_with_yank() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('c')); + assert_eq!( + event, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CutSelection, + EditCommand::HxClearSelection, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(hx.mode, HelixMode::Insert); + } + + #[test] + fn y_yanks_preserving_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('y')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::CopySelection, + ]) + ); + } + + #[test] + fn p_pastes_after_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('p')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + EditCommand::HxClearSelection, + EditCommand::PasteCutBufferBefore, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn big_p_pastes_before_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('P')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + EditCommand::HxClearSelection, + EditCommand::PasteCutBufferBefore, + EditCommand::HxRestartSelection, + ]) + ); + } + + // ── Selection command tests ─────────────────────────────────────── + + #[test] + fn semicolon_restarts_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key(';')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]) + ); + } + + #[test] + fn percent_selects_entire_buffer() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('%')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveToStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToEnd { select: false }, + EditCommand::HxSyncCursor, + ]) + ); + } + + #[test] + fn x_selects_entire_line() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('x')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToLineEnd { select: false }, + EditCommand::HxSyncCursor, + ]) + ); + } + + #[test] + fn o_flips_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('o')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::HxFlipSelection]) + ); + } + + // ── Undo/redo tests ─────────────────────────────────────────────── + + #[test] + fn u_undoes() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('u')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::Undo, EditCommand::HxRestartSelection,]) + ); + } + + #[test] + fn big_u_redoes() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('U')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::Redo, EditCommand::HxRestartSelection,]) + ); + } + + // ── Replace char test ───────────────────────────────────────────── + + #[test] + fn r_char_replaces_selection() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('r')); + let event = hx.parse_event(char_key('z')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxReplaceSelectionWithChar('z'), + ]) + ); + } + + // ── Tilde (switch case) test ────────────────────────────────────── + + #[test] + fn tilde_switches_case() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(char_key('~')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxSwitchCaseSelection, + ]) + ); + } + + // ── Esc in Normal/Select collapses pending state ────────────────── + + #[test] + fn esc_in_normal_resets_pending_and_count() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('5')); // count + hx.parse_event(char_key('g')); // pending goto + let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert_eq!(hx.mode, HelixMode::Normal); + assert_eq!(hx.pending, Pending::None); + assert_eq!(hx.count, 0); + } + + // ── Enter in Normal mode submits ────────────────────────────────── + + #[test] + fn enter_in_normal_submits() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + let event = hx.parse_event(key_press(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(event, ReedlineEvent::Enter); + } + + // ── F/T backward find/til tests ───────────────────────────────────── + + #[test] + fn big_f_char_produces_extending_motion() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('F')); + let event = hx.parse_event(char_key('a')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeftUntil { + c: 'a', + select: false + }, + EditCommand::HxSyncCursorWithRestart, + ]) + ); + } + + #[test] + fn big_t_char_produces_extending_motion() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('T')); + let event = hx.parse_event(char_key('a')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeftBefore { + c: 'a', + select: false + }, + EditCommand::HxSyncCursorWithRestart, + ]) + ); + } + + #[test] + fn count_with_f_produces_multiple_events() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + hx.parse_event(char_key('2')); + hx.parse_event(char_key('f')); + let event = hx.parse_event(char_key('x')); + // count=2: first has HxSyncCursorWithRestart, rest have HxSyncCursor + assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 2)); + } + + // ── %/x integration tests ─────────────────────────────────────────── + + #[test] + fn percent_selects_all_integration() { + let mut editor = editor_with("hello world", 3); + run_commands( + &mut editor, + &[ + EditCommand::MoveToStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToEnd { select: false }, + EditCommand::HxSyncCursor, + ], + ); + let sel = editor.hx_selection().expect("expected selection"); + assert_eq!(sel.range(), (0, 11)); + } + + #[test] + fn x_selects_line_integration() { + let mut editor = editor_with("hello world", 5); + run_commands( + &mut editor, + &[ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxRestartSelection, + EditCommand::MoveToLineEnd { select: false }, + EditCommand::HxSyncCursor, + ], + ); + let sel = editor.hx_selection().expect("expected selection"); + assert_eq!(sel.range(), (0, 11)); + } + + // ── Count prefix edge cases ───────────────────────────────────────── + + #[test] + fn count_zero_at_start_is_not_count() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + // '0' at start should not be a count digit (count is 0, so it's not > 0) + let event = hx.parse_event(char_key('0')); + // Falls through to match code block (no motion bound to '0') + assert_eq!(event, ReedlineEvent::None); + assert_eq!(hx.count, 0); + } + + #[test] + fn large_count_on_short_buffer_does_not_panic() { + let mut hx = Helix { + mode: HelixMode::Normal, + ..Default::default() + }; + // Enter count 100 + hx.parse_event(char_key('1')); + hx.parse_event(char_key('0')); + hx.parse_event(char_key('0')); + let event = hx.parse_event(char_key('h')); + // Should produce 100 MoveLeft + HxRestartSelection + match event { + ReedlineEvent::Edit(ref cmds) => { + assert_eq!(cmds.len(), 101); // 100 MoveLeft + 1 HxRestartSelection + } + _ => panic!("expected Edit event"), + } + } + + // ── UTF-8 word motion tests ───────────────────────────────────────── + + #[test] + fn test_w_utf8_cafe() { + let w = [EditCommand::HxWordMotion { + target: WordMotionTarget::NextWordStart, + movement: Movement::Move, + count: 1, + }]; + // "café world" — é is 2 bytes, so "café" ends at byte 5 + assert_sel(&w, "[c]afé world", "[café ]world"); + } + + #[test] + fn test_b_utf8_uber() { + let b = [EditCommand::HxWordMotion { + target: WordMotionTarget::PrevWordStart, + movement: Movement::Move, + count: 1, + }]; + // "über cool" — ü is 2 bytes + assert_sel(&b, "über coo[l]", "über ]cool["); + } + + // ── Esc from Insert resets insert_style ───────────────────────────── + + #[test] + fn esc_from_insert_resets_insert_style() { + let mut hx = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + ..Default::default() + }; + hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(hx.mode, HelixMode::Normal); + assert_eq!(hx.insert_style, InsertStyle::Plain); + } + + // ── f/t extending motion integration tests ────────────────────────── + + #[test] + fn f_char_integration() { + // "hello world" cursor at 0: f+o should select from h to o (inclusive) + let mut editor = editor_with("hello world", 0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + editor.run_edit_command(&EditCommand::MoveRightUntil { + c: 'o', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = editor.hx_selection().expect("expected selection"); + // Cursor was at 0, 'o' is at index 4. Selection should cover 0..5 + assert_eq!(sel.anchor, 0); + assert_eq!(sel.head, 5); + } + + #[test] + fn t_char_integration() { + // "hello world" cursor at 0: t+o should select from h to just before o + let mut editor = editor_with("hello world", 0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + editor.run_edit_command(&EditCommand::MoveRightBefore { + c: 'o', + select: false, + }); + editor.run_edit_command(&EditCommand::HxSyncCursorWithRestart); + let sel = editor.hx_selection().expect("expected selection"); + // 't' stops one before 'o' (index 4), so cursor at 3 → head=4 + assert_eq!(sel.anchor, 0); + assert_eq!(sel.head, 4); + } +} diff --git a/src/edit_mode/hx/word.rs b/src/edit_mode/hx/word.rs new file mode 100644 index 000000000..e4886d4af --- /dev/null +++ b/src/edit_mode/hx/word.rs @@ -0,0 +1,792 @@ +//! Helix-style word boundary detection and motion target computation. +//! +//! Helix classifies characters into three categories and treats transitions +//! between classes as word boundaries: +//! - **Word**: alphanumeric + underscore +//! - **Punctuation**: everything else that isn't whitespace +//! - **Whitespace**: spaces, tabs, etc. +//! +//! "Small word" motions (w/b/e) break at any class transition. +//! "Big WORD" motions (W/B/E) only break at whitespace boundaries. +//! +//! Motion functions take an `HxRange` (anchor + head) and return a new +//! `HxRange` with both anchor and head adjusted. This keeps boundary +//! detection, position advancement, and anchor adjustment atomic. + +use unicode_segmentation::UnicodeSegmentation; + +use crate::core_editor::HxRange; +use crate::enums::WordMotionTarget; + +/// Character classification for word boundary detection. +#[derive(Debug, PartialEq, Eq)] +enum CharClass { + Word, + Punctuation, + Whitespace, + /// Line endings are separate from whitespace (matching Helix). + /// This ensures `\n` creates a boundary with adjacent spaces. + Eol, +} + +/// Classify a character into Word, Eol, Whitespace, or Punctuation. +fn categorize_char(ch: char) -> CharClass { + match ch { + '\n' => CharClass::Eol, + ch if ch.is_alphanumeric() || ch == '_' => CharClass::Word, + ch if ch.is_whitespace() => CharClass::Whitespace, + _ => CharClass::Punctuation, + } +} + +fn is_word_boundary(a: char, b: char) -> bool { + categorize_char(a) != categorize_char(b) +} + +fn is_long_word_boundary(a: char, b: char) -> bool { + match (categorize_char(a), categorize_char(b)) { + (CharClass::Word, CharClass::Punctuation) | (CharClass::Punctuation, CharClass::Word) => { + false + } + (a, b) => a != b, + } +} + +/// Precomputed byte-offset table for char-index ↔ byte-offset conversion. +/// +/// Reedline uses byte offsets everywhere (insertion_point, HxRange), +/// but word motion logic iterates over `Vec` with char indices +/// (matching Helix's char-index Rope API). This table bridges the two +/// coordinate systems. +/// +/// Built once per word-motion invocation (O(n) where n = buffer chars). +/// Count-prefixed motions (e.g. `3w`) share a single `CharOffsets` +/// instance. For a line editor this overhead is negligible. +struct CharOffsets<'a> { + /// The original string slice (avoids re-allocation). + buf: &'a str, + /// `entries[i] = (byte_offset, char)` for char index `i`. + /// `entries[len]` has `byte_offset = buf.len()` (sentinel). + entries: Vec<(usize, char)>, +} + +impl<'a> CharOffsets<'a> { + fn new(buf: &'a str) -> Self { + let mut entries: Vec<(usize, char)> = buf.char_indices().collect(); + // Sentinel for one-past-end. The char value is unused — `char_at(len)` + // should never be called in production; the sentinel only provides + // `to_byte(len) == buf.len()`. + entries.push((buf.len(), '\0')); + Self { buf, entries } + } + + /// Number of real characters (excluding sentinel). + fn len(&self) -> usize { + self.entries.len() - 1 + } + + /// Character at char index `i`. + fn char_at(&self, i: usize) -> char { + self.entries[i].1 + } + + /// Convert a char index to a byte offset. Handles index == len (returns buf.len()). + fn to_byte(&self, char_idx: usize) -> usize { + self.entries[char_idx].0 + } + + /// Convert a byte offset to a char index. + /// + /// The offset must lie on a char boundary. In debug builds this panics + /// if the offset falls mid-character; in release builds it snaps to the + /// nearest char index to avoid silent end-of-buffer jumps. + fn to_char(&self, byte_offset: usize) -> usize { + match self + .entries + .binary_search_by_key(&byte_offset, |&(off, _)| off) + { + Ok(idx) => idx, + Err(idx) => { + debug_assert!( + false, + "to_char called with non-boundary byte offset {byte_offset}" + ); + idx.min(self.len()) + } + } + } + + /// Build an HxRange converting char indices back to byte offsets. + fn to_byte_range(&self, anchor: usize, head: usize) -> HxRange { + HxRange { + anchor: self.to_byte(anchor), + head: self.to_byte(head), + } + } + + /// Return the char index of the next grapheme boundary after `char_idx`. + /// Falls back to `char_idx + 1` clamped to len. + fn next_grapheme_char_idx(&self, char_idx: usize) -> usize { + if char_idx >= self.len() { + return self.len(); + } + let byte_start = self.to_byte(char_idx); + let slice = &self.buf[byte_start..]; + let first_grapheme_len = slice + .grapheme_indices(true) + .nth(1) + .map(|(offset, _)| offset) + .unwrap_or(slice.len()); + self.to_char(byte_start + first_grapheme_len) + } + + /// Return the char index of the previous grapheme boundary before `char_idx`. + /// Falls back to `char_idx - 1` clamped to 0. + fn prev_grapheme_char_idx(&self, char_idx: usize) -> usize { + if char_idx == 0 { + return 0; + } + let byte_end = self.to_byte(char_idx); + let slice = &self.buf[..byte_end]; + slice + .grapheme_indices(true) + .next_back() + .map(|(offset, _)| self.to_char(offset)) + .unwrap_or(0) + } +} + +// ── Forward word motions ──────────────────────────────────────────────── + +/// Target predicates for forward word motions. +/// `NextWordStart` stops when the *next* char is non-whitespace at a boundary. +/// `NextWordEnd` stops when the *previous* char is non-whitespace at a boundary. +fn reached_word_start(boundary_fn: fn(char, char) -> bool) -> impl Fn(char, char) -> bool { + move |prev, next| boundary_fn(prev, next) && (next == '\n' || !next.is_whitespace()) +} + +fn reached_word_end(boundary_fn: fn(char, char) -> bool) -> impl Fn(char, char) -> bool { + move |prev, next| boundary_fn(prev, next) && (!prev.is_whitespace() || next == '\n') +} + +/// Shared forward word motion implementation for both `w`/`W` and `e`/`E`. +/// +/// Advances through the buffer, stopping when `reached(prev_ch, next_ch)` +/// returns true at a boundary. The `reached` predicate is the only +/// difference between NextWordStart and NextWordEnd motions. +fn word_right( + buf: &str, + range: &HxRange, + count: usize, + reached: impl Fn(char, char) -> bool, +) -> HxRange { + let co = CharOffsets::new(buf); + let len = co.len(); + + let range_anchor = co.to_char(range.anchor); + let range_head = co.to_char(range.head); + + if len == 0 || range_head >= len { + return *range; + } + + // Prepare range for block-cursor semantics (matching helix word_move). + let (mut anchor, mut head) = if range_anchor < range_head { + (co.prev_grapheme_char_idx(range_head), range_head) + } else { + (range_head, co.next_grapheme_char_idx(range_head).min(len)) + }; + + for _ in 0..count { + if head >= len { + break; + } + + let mut prev_ch: Option = if head > 0 { + Some(co.char_at(head - 1)) + } else { + None + }; + + // Skip initial newlines. + while head < len && co.char_at(head) == '\n' { + prev_ch = Some(co.char_at(head)); + head += 1; + } + if prev_ch == Some('\n') { + anchor = head; + } + + let head_start = head; + + // Walk forward to target. + while head < len { + let next_ch = co.char_at(head); + if prev_ch.map_or(true, |p| reached(p, next_ch)) { + if head == head_start { + anchor = head; + } else { + break; + } + } + prev_ch = Some(next_ch); + head += 1; + } + } + + co.to_byte_range(anchor, head) +} + +/// `w` / `W` motion wrapper for tests. +#[cfg(test)] +fn word_right_start(buf: &str, range: &HxRange, count: usize, big: bool) -> HxRange { + let boundary_fn = if big { + is_long_word_boundary + } else { + is_word_boundary + }; + word_right(buf, range, count, reached_word_start(boundary_fn)) +} + +/// `e` / `E` motion wrapper for tests. +#[cfg(test)] +fn word_right_end(buf: &str, range: &HxRange, count: usize, big: bool) -> HxRange { + let boundary_fn = if big { + is_long_word_boundary + } else { + is_word_boundary + }; + word_right(buf, range, count, reached_word_end(boundary_fn)) +} + +// ── Backward word motions ─────────────────────────────────────────────── + +/// `b` / `B` motion: move to the start of the previous word. +/// +/// Moves to the first character of the previous word. +/// Anchor is adjusted at boundary skips. +/// +/// Algorithm (per iteration): +/// 1. If at boundary, step back and reset anchor. +/// 2. Skip whitespace going left. +/// 3. Go left through same-class run. +/// 4. Return final position. +fn word_left(buf: &str, range: &HxRange, count: usize, big: bool) -> HxRange { + let co = CharOffsets::new(buf); + let len = co.len(); + + let range_anchor = co.to_char(range.anchor); + let range_head = co.to_char(range.head); + + if len == 0 || range_head == 0 { + return *range; + } + + let boundary_fn: fn(char, char) -> bool = if big { + is_long_word_boundary + } else { + is_word_boundary + }; + + // PrevWordStart uses the same predicate as NextWordEnd but in reverse. + let reached = reached_word_end(boundary_fn); + + // Prepare range for block-cursor semantics (backward direction). + let (mut anchor, mut head) = if range_anchor < range_head { + (range_head, co.prev_grapheme_char_idx(range_head)) + } else { + (co.next_grapheme_char_idx(range_head).min(len), range_head) + }; + + for _ in 0..count { + if head == 0 { + break; + } + + // "prev_ch" in reverse iteration is the char at head (moving away from). + let mut prev_ch: Option = if head < len { + Some(co.char_at(head)) + } else { + None + }; + + // Skip initial newlines going backwards. + while head > 0 && co.char_at(head - 1) == '\n' { + head -= 1; + prev_ch = Some(co.char_at(head)); + } + if prev_ch == Some('\n') { + anchor = head; + } + + let head_start = head; + + // Walk backward to target. + while head > 0 { + let next_ch = co.char_at(head - 1); + if prev_ch.map_or(true, |p| reached(p, next_ch)) { + if head == head_start { + anchor = head; + } else { + break; + } + } + prev_ch = Some(next_ch); + head -= 1; + } + } + + co.to_byte_range(anchor, head) +} + +/// Dispatch a word motion by target and movement. +/// +/// Routes `WordMotionTarget` to the appropriate directional function, +/// extracting the `big` flag from the target itself. +pub(crate) fn word_move( + buf: &str, + range: &HxRange, + count: usize, + target: WordMotionTarget, +) -> HxRange { + use WordMotionTarget::*; + let big = matches!( + target, + NextLongWordStart | NextLongWordEnd | PrevLongWordStart + ); + let boundary_fn: fn(char, char) -> bool = if big { + is_long_word_boundary + } else { + is_word_boundary + }; + + match target { + NextWordStart | NextLongWordStart => { + word_right(buf, range, count, reached_word_start(boundary_fn)) + } + NextWordEnd | NextLongWordEnd => { + word_right(buf, range, count, reached_word_end(boundary_fn)) + } + PrevWordStart | PrevLongWordStart => word_left(buf, range, count, big), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Test runner ───────────────────────────────────────────────────── + // + // Adapted from helix-core/src/movement.rs test infrastructure. + // Internally word motions use char indices but convert to byte offsets + // via CharOffsets. All ASCII test strings have identical char/byte values. + + /// Run a motion function against a table of (sample, scenarios). + /// Each scenario is (count, begin_range, expected_range). + fn run_motion_tests( + f: fn(&str, &HxRange, usize, bool) -> HxRange, + big: bool, + tests: &[(&str, Vec<(usize, HxRange, HxRange)>)], + ) { + for (sample, scenarios) in tests { + for (count, begin, expected) in scenarios { + let result = f(sample, begin, *count, big); + assert_eq!( + result, *expected, + "\n sample: {:?}\n count: {}\n begin: ({}, {})\n expected: ({}, {})\n got: ({}, {})", + sample, count, + begin.anchor, begin.head, + expected.anchor, expected.head, + result.anchor, result.head, + ); + } + } + } + + /// Shorthand for HxRange construction in test tables. + fn r(anchor: usize, head: usize) -> HxRange { + HxRange { anchor, head } + } + + // ── w (next word start) ───────────────────────────────────────────── + // Test cases from helix-core/src/movement.rs, adapted for byte offsets. + + #[test] + fn test_w() { + run_motion_tests(word_right_start, false, &[ + ("Basic forward motion stops at the first space", + vec![(1, r(0, 0), r(0, 6))]), + (" Starting from a boundary advances the anchor", + vec![(1, r(0, 0), r(1, 10))]), + ("Long whitespace gap is bridged by the head", + vec![(1, r(0, 0), r(0, 11))]), + ("Previous anchor is irrelevant for forward motions", + vec![(1, r(12, 0), r(0, 9))]), + (" Starting from whitespace moves to last space in sequence", + vec![(1, r(0, 0), r(0, 4))]), + ("Starting from mid-word leaves anchor at start position and moves head", + vec![(1, r(3, 3), r(3, 9))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 0), r(0, 29))]), + ("Jumping\n into starting whitespace selects the spaces before 'into'", + vec![(1, r(0, 7), r(8, 12))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, r(0, 0), r(0, 12)), + (1, r(0, 12), r(12, 15)), + (1, r(12, 15), r(15, 18)), + ]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 0), r(0, 6)), + (1, r(0, 6), r(6, 10)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, r(0, 0), r(0, 2))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, r(0, 0), r(0, 8)), + (1, r(0, 8), r(10, 14)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects whitespace.", + vec![(1, r(0, 9), r(13, 16))]), + ("A failed motion does not modify the range", + vec![(3, r(37, 41), r(37, 41))]), + ("oh oh oh two character words!", + vec![ + (1, r(0, 0), r(0, 3)), + (1, r(0, 3), r(3, 6)), + (1, r(0, 2), r(1, 3)), + ]), + ("Multiple motions at once resolve correctly", + vec![(3, r(0, 0), r(17, 20))]), + ("Excessive motions are performed partially", + vec![(999, r(0, 0), r(32, 41))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(0, 0), r(5, 5))]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 0), r(1, 4)), + (1, r(1, 4), r(5, 8)), + ]), + ]); + } + + // ── W (next WORD start) ───────────────────────────────────────────── + + #[test] + fn test_big_w() { + run_motion_tests(word_right_start, true, &[ + ("Basic forward motion stops at the first space", + vec![(1, r(0, 0), r(0, 6))]), + (" Starting from a boundary advances the anchor", + vec![(1, r(0, 0), r(1, 10))]), + ("Long whitespace gap is bridged by the head", + vec![(1, r(0, 0), r(0, 11))]), + ("Previous anchor is irrelevant for forward motions", + vec![(1, r(12, 0), r(0, 9))]), + (" Starting from whitespace moves to last space in sequence", + vec![(1, r(0, 0), r(0, 4))]), + ("Starting from mid-word leaves anchor at start position and moves head", + vec![(1, r(3, 3), r(3, 9))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 0), r(0, 29))]), + ("Jumping\n into starting whitespace selects the spaces before 'into'", + vec![(1, r(0, 7), r(8, 12))]), + ("alphanumeric.!,and.?=punctuation are not treated any differently than alphanumerics", + vec![(1, r(0, 0), r(0, 33))]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 0), r(0, 6)), + (1, r(0, 6), r(6, 10)), + ]), + (".._.._ punctuation is joined by underscores into a single word, as it behaves like alphanumerics", + vec![(1, r(0, 0), r(0, 7))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, r(0, 0), r(0, 8)), + (1, r(0, 8), r(10, 14)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects whitespace.", + vec![(1, r(0, 9), r(13, 16))]), + ("A failed motion does not modify the range", + vec![(3, r(37, 41), r(37, 41))]), + ("oh oh oh two character words!", + vec![ + (1, r(0, 0), r(0, 3)), + (1, r(0, 3), r(3, 6)), + (1, r(0, 1), r(0, 3)), + ]), + ("Multiple motions at once resolve correctly", + vec![(3, r(0, 0), r(17, 20))]), + ("Excessive motions are performed partially", + vec![(999, r(0, 0), r(32, 41))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(0, 0), r(5, 5))]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 0), r(1, 4)), + (1, r(1, 4), r(5, 8)), + ]), + ]); + } + + // ── b (previous word start) ───────────────────────────────────────── + + #[test] + fn test_b() { + run_motion_tests(word_left, false, &[ + ("Basic backward motion from the middle of a word", + vec![(1, r(3, 3), r(4, 0))]), + (" Jump to start of a word preceded by whitespace", + vec![(1, r(5, 5), r(6, 4))]), + (" Jump to start of line from start of word preceded by whitespace", + vec![(1, r(4, 4), r(4, 0))]), + ("Previous anchor is irrelevant for backward motions", + vec![(1, r(12, 5), r(6, 0))]), + (" Starting from whitespace moves to first space in sequence", + vec![(1, r(0, 4), r(4, 0))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 20), r(20, 0))]), + ("Jumping\n \nback through a newline selects whitespace", + vec![(1, r(0, 13), r(12, 8))]), + ("Jumping to start of word from the end selects the word", + vec![(1, r(6, 7), r(7, 0))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, r(29, 30), r(30, 21)), + (1, r(30, 21), r(21, 18)), + (1, r(21, 18), r(18, 15)), + ]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 10), r(10, 6)), + (1, r(10, 6), r(6, 0)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, r(0, 6), r(5, 3))]), + ("Newlines\n\nare bridged seamlessly.", + vec![(1, r(0, 10), r(8, 0))]), + ("Jumping \n\n\n\n\nback from within a newline group selects previous block", + vec![(1, r(0, 13), r(11, 0))]), + ("Failed motions do not modify the range", + vec![(0, r(3, 0), r(3, 0))]), + ("Multiple motions at once resolve correctly", + vec![(3, r(18, 18), r(9, 0))]), + ("Excessive motions are performed partially", + vec![(999, r(40, 40), r(10, 0))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(5, 5), r(0, 0))]), + (" \n \nJumping back through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 8), r(7, 4)), + (1, r(7, 4), r(3, 0)), + ]), + ]); + } + + // ── B (previous WORD start) ───────────────────────────────────────── + + #[test] + fn test_big_b() { + run_motion_tests(word_left, true, &[ + ("Basic backward motion from the middle of a word", + vec![(1, r(3, 3), r(4, 0))]), + (" Jump to start of a word preceded by whitespace", + vec![(1, r(5, 5), r(6, 4))]), + (" Jump to start of line from start of word preceded by whitespace", + vec![(1, r(3, 4), r(4, 0))]), + ("Previous anchor is irrelevant for backward motions", + vec![(1, r(12, 5), r(6, 0))]), + (" Starting from whitespace moves to first space in sequence", + vec![(1, r(0, 4), r(4, 0))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 20), r(20, 0))]), + ("Jumping\n \nback through a newline selects whitespace", + vec![(1, r(0, 13), r(12, 8))]), + ("Jumping to start of word from the end selects the word", + vec![(1, r(6, 7), r(7, 0))]), + ("alphanumeric.!,and.?=punctuation are treated exactly the same", + vec![(1, r(29, 30), r(30, 0))]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 10), r(10, 6)), + (1, r(10, 6), r(6, 0)), + ]), + (".._.._ punctuation is joined by underscores into a single block", + vec![(1, r(0, 6), r(6, 0))]), + ("Newlines\n\nare bridged seamlessly.", + vec![(1, r(0, 10), r(8, 0))]), + ("Jumping \n\n\n\n\nback from within a newline group selects previous block", + vec![(1, r(0, 13), r(11, 0))]), + ("Failed motions do not modify the range", + vec![(0, r(3, 0), r(3, 0))]), + ("Multiple motions at once resolve correctly", + vec![(3, r(19, 19), r(9, 0))]), + ("Excessive motions are performed partially", + vec![(999, r(40, 40), r(10, 0))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(5, 5), r(0, 0))]), + (" \n \nJumping back through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 8), r(7, 4)), + (1, r(7, 4), r(3, 0)), + ]), + ]); + } + + // ── e (next word end) ─────────────────────────────────────────────── + + #[test] + fn test_e() { + run_motion_tests(word_right_end, false, &[ + ("Basic forward motion from the start of a word to the end of it", + vec![(1, r(0, 0), r(0, 5))]), + ("Basic forward motion from the end of a word to the end of the next", + vec![(1, r(0, 5), r(5, 13))]), + ("Basic forward motion from the middle of a word to the end of it", + vec![(1, r(2, 2), r(2, 5))]), + (" Jumping to end of a word preceded by whitespace", + vec![(1, r(0, 0), r(0, 11))]), + ("Previous anchor is irrelevant for end of word motion", + vec![(1, r(12, 2), r(2, 8))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 0), r(0, 28))]), + ("Jumping\n into starting whitespace selects up to the end of next word", + vec![(1, r(0, 7), r(8, 16))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, r(0, 0), r(0, 12)), + (1, r(0, 12), r(12, 15)), + (1, r(12, 15), r(15, 18)), + ]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 0), r(0, 3)), + (1, r(0, 3), r(3, 9)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, r(0, 0), r(0, 2))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, r(0, 0), r(0, 8)), + (1, r(0, 8), r(10, 13)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects to end of next word.", + vec![(1, r(0, 8), r(13, 20))]), + ("A failed motion does not modify the range", + vec![(3, r(37, 41), r(37, 41))]), + ("Multiple motions at once resolve correctly", + vec![(3, r(0, 0), r(16, 19))]), + ("Excessive motions are performed partially", + vec![(999, r(0, 0), r(31, 41))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(0, 0), r(5, 5))]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 0), r(1, 4)), + (1, r(1, 4), r(5, 8)), + ]), + ]); + } + + // ── E (next WORD end) ─────────────────────────────────────────────── + + #[test] + fn test_big_e() { + run_motion_tests(word_right_end, true, &[ + ("Basic forward motion from the start of a word to the end of it", + vec![(1, r(0, 0), r(0, 5))]), + ("Basic forward motion from the end of a word to the end of the next", + vec![(1, r(0, 5), r(5, 13))]), + ("Basic forward motion from the middle of a word to the end of it", + vec![(1, r(2, 2), r(2, 5))]), + (" Jumping to end of a word preceded by whitespace", + vec![(1, r(0, 0), r(0, 11))]), + ("Previous anchor is irrelevant for end of word motion", + vec![(1, r(12, 2), r(2, 8))]), + ("Identifiers_with_underscores are considered a single word", + vec![(1, r(0, 0), r(0, 28))]), + ("Jumping\n into starting whitespace selects up to the end of next word", + vec![(1, r(0, 7), r(8, 16))]), + ("alphanumeric.!,and.?=punctuation are treated the same way", + vec![(1, r(0, 0), r(0, 32))]), + ("... ... punctuation and spaces behave as expected", + vec![ + (1, r(0, 0), r(0, 3)), + (1, r(0, 3), r(3, 9)), + ]), + (".._.._ punctuation is joined by underscores into a single block", + vec![(1, r(0, 0), r(0, 6))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, r(0, 0), r(0, 8)), + (1, r(0, 8), r(10, 13)), + ]), + ("Jumping\n\n\n\n\n\n from newlines to whitespace selects to end of next word.", + vec![(1, r(0, 9), r(13, 20))]), + ("A failed motion does not modify the range", + vec![(3, r(37, 41), r(37, 41))]), + ("Multiple motions at once resolve correctly", + vec![(3, r(0, 0), r(16, 19))]), + ("Excessive motions are performed partially", + vec![(999, r(0, 0), r(31, 41))]), + ("", + vec![(1, r(0, 0), r(0, 0))]), + ("\n\n\n\n\n", + vec![(1, r(0, 0), r(5, 5))]), + ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks", + vec![ + (1, r(0, 0), r(1, 4)), + (1, r(1, 4), r(5, 8)), + ]), + ]); + } + + // ── Non-ASCII / multi-byte UTF-8 ────────────────────────────────── + + #[test] + fn test_w_multibyte() { + // "café world" — 'é' is 2 bytes (U+00E9), so byte offsets diverge from char indices. + // char indices: c=0 a=1 f=2 é=3 ' '=4 w=5 o=6 r=7 l=8 d=9 + // byte offsets: c=0 a=1 f=2 é=3 ' '=5 w=6 o=7 r=8 l=9 d=10 + let buf = "café world"; + assert_eq!(buf.len(), 11); // 10 chars but 11 bytes + + // w from start: "café " → anchor=0, head at 'w' (byte 6) + // In char indices: anchor=0, head=5 → byte: anchor=0, head=6 + // But word_right_start selects "café " as anchor=0, head=5 (chars) → bytes 0, 6 + let result = word_right_start(buf, &r(0, 0), 1, false); + assert_eq!(result.anchor, 0); // byte offset of char 0 + assert_eq!(result.head, 6); // byte offset of char 5 ('w' = byte 6) + } + + #[test] + fn test_b_multibyte() { + let buf = "café world"; + // b from end (byte 10, char 9): should go to "world" start (byte 6) + let result = word_left(buf, &r(10, 10), 1, false); + assert_eq!(result.head, 6); // byte offset of 'w' + } + + #[test] + fn test_e_multibyte() { + let buf = "café world"; + // e from start: should go to end of "café" (byte 4 = end of 'é', char 3) + // In Helix semantics, head goes past the word end so: + // char anchor=0, head=4 → byte anchor=0, head=5 + let result = word_right_end(buf, &r(0, 0), 1, false); + assert_eq!(result.anchor, 0); + assert_eq!(result.head, 5); // byte offset past 'é' (byte 3+2=5) + } +} diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 38e1456f5..576f08d7f 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -1,11 +1,17 @@ mod base; mod cursors; mod emacs; +#[cfg(feature = "hx")] +pub(crate) mod hx; mod keybindings; mod vi; pub use base::EditMode; pub use cursors::CursorConfig; +#[cfg(feature = "hx")] +pub use cursors::{HX_CURSOR_INSERT, HX_CURSOR_NORMAL, HX_CURSOR_SELECT}; pub use emacs::{default_emacs_keybindings, Emacs}; +#[cfg(feature = "hx")] +pub use hx::Helix; pub use keybindings::Keybindings; pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; diff --git a/src/engine.rs b/src/engine.rs index e67df6e77..92b2434f3 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1474,6 +1474,8 @@ impl Reedline { self.update_buffer_from_history(); self.editor.move_to_start(false); self.editor.move_to_line_end(false); + #[cfg(feature = "hx")] + self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation); } @@ -1508,6 +1510,8 @@ impl Reedline { } self.update_buffer_from_history(); self.editor.move_to_end(false); + #[cfg(feature = "hx")] + self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation) } @@ -1595,6 +1599,12 @@ impl Reedline { /// When using the up/down traversal or fish/zsh style prefix search update the main line buffer accordingly. /// Not used for the separate modal reverse search! fn update_buffer_from_history(&mut self) { + // When the buffer is replaced by history navigation, any Helix-mode + // selection is stale (byte offsets from the old buffer). Clear it so + // the caller can re-establish a fresh selection after cursor positioning. + #[cfg(feature = "hx")] + self.editor.reset_hx_state(); + match self.history_cursor.get_navigation() { _ if self.history_cursor_on_excluded => self.editor.set_buffer( self.history_excluded_item diff --git a/src/enums.rs b/src/enums.rs index 5278fa88c..78b5ad1e8 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -466,6 +466,107 @@ pub enum EditCommand { /// The text object to operate on text_object: TextObject, }, + + /// Reset the hx selection anchor+head to the current insertion point + #[cfg(feature = "hx")] + HxRestartSelection, + + /// Clear hx selection entirely (e.g. when entering Insert mode) + #[cfg(feature = "hx")] + HxClearSelection, + + /// Ensure an hx selection exists; no-op if one is already set + #[cfg(feature = "hx")] + HxEnsureSelection, + + /// Adjust insertion point to block-cursor display position after a motion + #[cfg(feature = "hx")] + HxSyncCursor, + + /// Atomic restart + sync for extending motions (f/t/F/T) in Normal mode. + /// + /// If the cursor moved from the current selection's display position, + /// restarts the anchor at the old cursor and syncs the head to the new + /// position. If the cursor did not move, the selection is left + /// unchanged — preventing collapse on repeated motions like `t`. + #[cfg(feature = "hx")] + HxSyncCursorWithRestart, + + /// Helix word/WORD motion (w/b/e/W/B/E) + #[cfg(feature = "hx")] + HxWordMotion { + /// Which word motion to perform + target: WordMotionTarget, + /// Whether to reset or extend the selection anchor + movement: Movement, + /// Repeat count (from numeric prefix, minimum 1) + count: usize, + }, + + /// Flip the Helix selection (swap anchor and head) + #[cfg(feature = "hx")] + HxFlipSelection, + + /// Move cursor to the start of the Helix selection (min of anchor, head) + #[cfg(feature = "hx")] + HxMoveToSelectionStart, + + /// Move cursor to the end of the Helix selection (max of anchor, head) + #[cfg(feature = "hx")] + HxMoveToSelectionEnd, + + /// Toggle case of entire Helix selection + #[cfg(feature = "hx")] + HxSwitchCaseSelection, + + /// Replace every grapheme in the Helix selection with the given char + #[cfg(feature = "hx")] + HxReplaceSelectionWithChar(char), + + /// Delete the Helix selection range without saving to cut buffer + #[cfg(feature = "hx")] + HxDeleteSelection, + + /// Extend the Helix selection head to the current insertion point. + /// Used after `a` (append) so the selection grows as the user types. + #[cfg(feature = "hx")] + HxExtendSelectionToInsertionPoint, + + /// Shift both anchor and head of the Helix selection forward so the + /// selection tracks text that was pushed right by an insertion before it. + /// Used after `i` (insert) so the selection stays on the same text. + #[cfg(feature = "hx")] + HxShiftSelectionToInsertionPoint, +} + +/// Whether a motion resets the selection anchor (Move) or keeps it (Extend). +#[cfg(feature = "hx")] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Movement { + /// Normal mode: anchor follows boundary logic + #[default] + Move, + /// Select mode: anchor stays fixed + Extend, +} + +/// Which word motion to perform. Encodes direction and big/small. +#[cfg(feature = "hx")] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WordMotionTarget { + /// `w` -- next word start + #[default] + NextWordStart, + /// `e` -- next word end + NextWordEnd, + /// `b` -- previous word start + PrevWordStart, + /// `W` -- next WORD start + NextLongWordStart, + /// `E` -- next WORD end + NextLongWordEnd, + /// `B` -- previous WORD start + PrevLongWordStart, } impl Display for EditCommand { @@ -597,6 +698,42 @@ impl Display for EditCommand { EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), + #[cfg(feature = "hx")] + EditCommand::HxRestartSelection => write!(f, "HxRestartSelection"), + #[cfg(feature = "hx")] + EditCommand::HxClearSelection => write!(f, "HxClearSelection"), + #[cfg(feature = "hx")] + EditCommand::HxEnsureSelection => write!(f, "HxEnsureSelection"), + #[cfg(feature = "hx")] + EditCommand::HxSyncCursor => write!(f, "HxSyncCursor"), + #[cfg(feature = "hx")] + EditCommand::HxSyncCursorWithRestart => write!(f, "HxSyncCursorWithRestart"), + #[cfg(feature = "hx")] + EditCommand::HxWordMotion { target, .. } => { + write!(f, "HxWordMotion({:?})", target) + } + #[cfg(feature = "hx")] + EditCommand::HxFlipSelection => write!(f, "HxFlipSelection"), + #[cfg(feature = "hx")] + EditCommand::HxMoveToSelectionStart => write!(f, "HxMoveToSelectionStart"), + #[cfg(feature = "hx")] + EditCommand::HxMoveToSelectionEnd => write!(f, "HxMoveToSelectionEnd"), + #[cfg(feature = "hx")] + EditCommand::HxSwitchCaseSelection => write!(f, "HxSwitchCaseSelection"), + #[cfg(feature = "hx")] + EditCommand::HxReplaceSelectionWithChar(c) => { + write!(f, "HxReplaceSelectionWithChar({c})") + } + #[cfg(feature = "hx")] + EditCommand::HxDeleteSelection => write!(f, "HxDeleteSelection"), + #[cfg(feature = "hx")] + EditCommand::HxExtendSelectionToInsertionPoint => { + write!(f, "HxExtendSelectionToInsertionPoint") + } + #[cfg(feature = "hx")] + EditCommand::HxShiftSelectionToInsertionPoint => { + write!(f, "HxShiftSelectionToInsertionPoint") + } } } } @@ -709,6 +846,25 @@ impl EditCommand { | EditCommand::CopyInsidePair { .. } | EditCommand::CopyAroundPair { .. } | EditCommand::CopyTextObject { .. } => EditType::NoOp, + + #[cfg(feature = "hx")] + EditCommand::HxRestartSelection + | EditCommand::HxClearSelection + | EditCommand::HxEnsureSelection + | EditCommand::HxSyncCursor + | EditCommand::HxSyncCursorWithRestart + | EditCommand::HxFlipSelection + | EditCommand::HxMoveToSelectionStart + | EditCommand::HxMoveToSelectionEnd => EditType::NoOp, + #[cfg(feature = "hx")] + EditCommand::HxWordMotion { .. } => EditType::MoveCursor { select: false }, + #[cfg(feature = "hx")] + EditCommand::HxSwitchCaseSelection + | EditCommand::HxReplaceSelectionWithChar(_) + | EditCommand::HxDeleteSelection => EditType::EditText, + #[cfg(feature = "hx")] + EditCommand::HxExtendSelectionToInsertionPoint + | EditCommand::HxShiftSelectionToInsertionPoint => EditType::NoOp, } } } diff --git a/src/lib.rs b/src/lib.rs index 4bd27db74..45818c70b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -243,6 +243,8 @@ pub use enums::{ EditCommand, MouseButton, ReedlineEvent, ReedlineRawEvent, Signal, TextObject, TextObjectScope, TextObjectType, UndoBehavior, }; +#[cfg(feature = "hx")] +pub use enums::{Movement, WordMotionTarget}; mod painting; pub use painting::{Painter, StyledText}; @@ -263,16 +265,22 @@ pub use history::{ }; mod prompt; +#[cfg(feature = "hx")] +pub use prompt::PromptHelixMode; pub use prompt::{ DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, }; mod edit_mode; +#[cfg(feature = "hx")] +pub use edit_mode::Helix; pub use edit_mode::{ default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, CursorConfig, EditMode, Emacs, Keybindings, Vi, }; +#[cfg(feature = "hx")] +pub use edit_mode::{HX_CURSOR_INSERT, HX_CURSOR_NORMAL, HX_CURSOR_SELECT}; mod highlighter; pub use highlighter::{ExampleHighlighter, Highlighter, SimpleMatchHighlighter}; diff --git a/src/painting/painter.rs b/src/painting/painter.rs index 1cf06b295..b7beb385e 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -476,6 +476,18 @@ impl Painter { PromptEditMode::Emacs => shapes.emacs, PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert, PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal, + #[cfg(feature = "hx")] + PromptEditMode::Helix(ref mode) => { + use crate::{HX_CURSOR_INSERT, HX_CURSOR_NORMAL, HX_CURSOR_SELECT}; + let key = match mode { + crate::PromptHelixMode::Normal => HX_CURSOR_NORMAL, + crate::PromptHelixMode::Insert => HX_CURSOR_INSERT, + crate::PromptHelixMode::Select => HX_CURSOR_SELECT, + }; + shapes.custom.get(key).copied() + } + #[cfg(feature = "hx")] + PromptEditMode::Custom(name) => shapes.custom.get(name.as_str()).copied(), _ => None, }; if let Some(shape) = shape { diff --git a/src/prompt/base.rs b/src/prompt/base.rs index 60a37010b..6b622a737 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -55,6 +55,10 @@ pub enum PromptEditMode { /// A vi-specific mode Vi(PromptViMode), + /// A helix-specific mode + #[cfg(feature = "hx")] + Helix(PromptHelixMode), + /// A custom mode Custom(String), } @@ -70,12 +74,31 @@ pub enum PromptViMode { Insert, } +/// The helix-specific modes that the prompt can be in +#[cfg(feature = "hx")] +#[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] +pub enum PromptHelixMode { + /// Normal (command) mode + #[default] + Normal, + + /// Insert mode + Insert, + + /// Select (visual) mode + Select, +} + impl Display for PromptEditMode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { PromptEditMode::Default => write!(f, "Default"), PromptEditMode::Emacs => write!(f, "Emacs"), PromptEditMode::Vi(_) => write!(f, "Vi_Normal\nVi_Insert"), + #[cfg(feature = "hx")] + PromptEditMode::Helix(_) => { + write!(f, "Helix_Normal\nHelix_Insert\nHelix_Select") + } PromptEditMode::Custom(s) => write!(f, "Custom_{s}"), } } diff --git a/src/prompt/default.rs b/src/prompt/default.rs index 24625a2c0..f8953268a 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "hx")] +use crate::PromptHelixMode; use crate::{Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode}; use { @@ -11,6 +13,16 @@ pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": "; pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "〉"; pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; +/// The default prompt indicator for helix normal mode +#[cfg(feature = "hx")] +pub static DEFAULT_HX_NORMAL_PROMPT_INDICATOR: &str = "〉"; +/// The default prompt indicator for helix insert mode +#[cfg(feature = "hx")] +pub static DEFAULT_HX_INSERT_PROMPT_INDICATOR: &str = ": "; +/// The default prompt indicator for helix select mode +#[cfg(feature = "hx")] +pub static DEFAULT_HX_SELECT_PROMPT_INDICATOR: &str = "» "; + /// Simple [`Prompt`] displaying a configurable left and a right prompt. /// For more fine-tuned configuration, implement the [`Prompt`] trait. /// For the default configuration, use [`DefaultPrompt::default()`] @@ -66,6 +78,12 @@ impl Prompt for DefaultPrompt { PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), }, + #[cfg(feature = "hx")] + PromptEditMode::Helix(hx_mode) => match hx_mode { + PromptHelixMode::Normal => DEFAULT_HX_NORMAL_PROMPT_INDICATOR.into(), + PromptHelixMode::Insert => DEFAULT_HX_INSERT_PROMPT_INDICATOR.into(), + PromptHelixMode::Select => DEFAULT_HX_SELECT_PROMPT_INDICATOR.into(), + }, PromptEditMode::Custom(str) => format!("({str})").into(), } } @@ -137,3 +155,54 @@ fn get_now() -> String { let now = Local::now(); format!("{:>}", now.format("%m/%d/%Y %I:%M:%S %p")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Prompt; + + #[test] + fn default_prompt_vi_indicators() { + let prompt = DefaultPrompt::default(); + assert_eq!( + prompt.render_prompt_indicator(PromptEditMode::Vi(PromptViMode::Normal)), + DEFAULT_VI_NORMAL_PROMPT_INDICATOR + ); + assert_eq!( + prompt.render_prompt_indicator(PromptEditMode::Vi(PromptViMode::Insert)), + DEFAULT_VI_INSERT_PROMPT_INDICATOR + ); + } + + #[cfg(feature = "hx")] + #[test] + fn default_prompt_helix_indicators() { + use crate::PromptHelixMode; + + let prompt = DefaultPrompt::default(); + assert_eq!( + prompt.render_prompt_indicator(PromptEditMode::Helix(PromptHelixMode::Normal)), + DEFAULT_HX_NORMAL_PROMPT_INDICATOR + ); + assert_eq!( + prompt.render_prompt_indicator(PromptEditMode::Helix(PromptHelixMode::Insert)), + DEFAULT_HX_INSERT_PROMPT_INDICATOR + ); + assert_eq!( + prompt.render_prompt_indicator(PromptEditMode::Helix(PromptHelixMode::Select)), + DEFAULT_HX_SELECT_PROMPT_INDICATOR + ); + } + + #[cfg(feature = "hx")] + #[test] + fn helix_display_impl() { + use crate::PromptHelixMode; + + let mode = PromptEditMode::Helix(PromptHelixMode::Normal); + let display = format!("{mode}"); + assert!(display.contains("Helix_Normal")); + assert!(display.contains("Helix_Insert")); + assert!(display.contains("Helix_Select")); + } +} diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 83d2e3b56..572ffb91f 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -1,6 +1,8 @@ mod base; mod default; +#[cfg(feature = "hx")] +pub use base::PromptHelixMode; pub use base::{ Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, }; From da6ae4846958dd9a6a9f62f9f39c979ae3f11b9a Mon Sep 17 00:00:00 2001 From: schlich Date: Sat, 7 Mar 2026 13:29:33 +0000 Subject: [PATCH 02/15] fix: typo exclusion for test case --- .typos.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.typos.toml b/.typos.toml index 805fe6389..76d05433c 100644 --- a/.typos.toml +++ b/.typos.toml @@ -21,3 +21,4 @@ l3ine = "l3ine" 4should = "4should" wr5ap = "wr5ap" ine = "ine" +worl = "worl" From ceeeba6046c42952590bdd3311983124531fb8bc Mon Sep 17 00:00:00 2001 From: schlich Date: Sat, 7 Mar 2026 13:40:35 +0000 Subject: [PATCH 03/15] fix: feature-gate cursor_config instantiation --- examples/demo.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/demo.rs b/examples/demo.rs index eec99b7ac..5e3b65814 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -73,6 +73,7 @@ fn main() -> reedline::Result<()> { ]; let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); + #[cfg(feature = "hx")] let cursor_config = CursorConfig { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), @@ -80,6 +81,13 @@ fn main() -> reedline::Result<()> { ..CursorConfig::default() }; + #[cfg(not(feature = "hx"))] + let cursor_config = CursorConfig { + vi_insert: Some(SetCursorStyle::BlinkingBar), + vi_normal: Some(SetCursorStyle::SteadyBlock), + emacs: None, + }; + let mut line_editor = Reedline::create() .with_history_session_id(history_session_id) .with_history(history) From 4a81f4232e66df4cdd5d5c198f3e40a8e824c6c4 Mon Sep 17 00:00:00 2001 From: Ty Schlichenmeyer Date: Sat, 7 Mar 2026 09:53:49 -0600 Subject: [PATCH 04/15] Update src/edit_mode/hx/word.rs Co-authored-by: Martin Kronberger --- src/edit_mode/hx/word.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/edit_mode/hx/word.rs b/src/edit_mode/hx/word.rs index e4886d4af..6b26e7a50 100644 --- a/src/edit_mode/hx/word.rs +++ b/src/edit_mode/hx/word.rs @@ -6,8 +6,13 @@ //! - **Punctuation**: everything else that isn't whitespace //! - **Whitespace**: spaces, tabs, etc. //! -//! "Small word" motions (w/b/e) break at any class transition. -//! "Big WORD" motions (W/B/E) only break at whitespace boundaries. + //! "Small word" motions (w/b/e) break at any class transition. + //! "Big WORD" motions (W/B/E) only break at whitespace boundaries. + //! + //! Reedline's existing word motions use Unicode word boundary rules. + //! Helix uses a simpler three-class model where `_` is a word character + //! (so `hello_world` is one word) and apostrophes are punctuation + //! (so `don't` is three segments). Line endings are also a distinct class. //! //! Motion functions take an `HxRange` (anchor + head) and return a new //! `HxRange` with both anchor and head adjusted. This keeps boundary From f79e97af986b63f0535e6c7a824db7743c19977e Mon Sep 17 00:00:00 2001 From: Ty Schlichenmeyer Date: Thu, 12 Mar 2026 03:12:58 -0500 Subject: [PATCH 05/15] Update src/edit_mode/hx/mod.rs Co-authored-by: Martin Kronberger --- src/edit_mode/hx/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index 9c4c45fc3..84bbaa91f 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -963,7 +963,7 @@ mod tests { } #[test] - fn delete_in_insert_clears_selection() { + fn delete_in_insert_preserves_selection() { let mut hx = Helix { mode: HelixMode::Insert, insert_style: InsertStyle::Before, @@ -972,9 +972,9 @@ mod tests { let event = hx.parse_event(key_press(KeyCode::Delete, KeyModifiers::NONE)); assert_eq!( event, - ReedlineEvent::Edit(vec![EditCommand::Delete, EditCommand::HxClearSelection]) + ReedlineEvent::Edit(vec![EditCommand::Delete]) ); - assert_eq!(hx.insert_style, InsertStyle::Plain); + assert_eq!(hx.insert_style, InsertStyle::Before); } #[test] From 84b0069115bc99fac3a2ea3de9f5070cc5a0964d Mon Sep 17 00:00:00 2001 From: Ty Schlichenmeyer Date: Thu, 12 Mar 2026 03:13:22 -0500 Subject: [PATCH 06/15] Update src/edit_mode/hx/mod.rs Co-authored-by: Martin Kronberger --- src/edit_mode/hx/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index 84bbaa91f..c52e404d0 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -590,7 +590,7 @@ impl EditMode for Helix { }, (KeyCode::Delete, _) => { self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![EditCommand::Delete, EditCommand::HxClearSelection]) + ReedlineEvent::Edit(vec![EditCommand::Delete]) } // Arrow keys / Home / End move the cursor away from the // insert position — clear the selection since byte offsets From 5249418fd7c8ce2a34d9a3efd529c8d93d6eee14 Mon Sep 17 00:00:00 2001 From: Ty Schlichenmeyer Date: Thu, 12 Mar 2026 03:13:55 -0500 Subject: [PATCH 07/15] Update src/edit_mode/hx/word.rs Co-authored-by: Martin Kronberger --- src/edit_mode/hx/word.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/edit_mode/hx/word.rs b/src/edit_mode/hx/word.rs index 6b26e7a50..898640f26 100644 --- a/src/edit_mode/hx/word.rs +++ b/src/edit_mode/hx/word.rs @@ -382,7 +382,7 @@ mod tests { // ── Test runner ───────────────────────────────────────────────────── // - // Adapted from helix-core/src/movement.rs test infrastructure. + // Adapted from [helix-core/src/movement.rs](https://github.com/helix-editor/helix/blob/51ec572a27a8c1267afbc07e6c1583585c6363dc/helix-core/src/movement.rs) test infrastructure. // Internally word motions use char indices but convert to byte offsets // via CharOffsets. All ASCII test strings have identical char/byte values. From 0900b6409a4c24664b2e8555c912e46b918fe135 Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 10:12:20 +0000 Subject: [PATCH 08/15] replace all instances of "hx" feature name to "helix" to match main branch --- Cargo.toml | 5 --- examples/demo.rs | 4 +- src/core_editor/editor.rs | 88 +++++++++++++++++++-------------------- src/core_editor/mod.rs | 2 +- src/edit_mode/cursors.rs | 18 ++++---- src/engine.rs | 6 +-- src/enums.rs | 68 +++++++++++++++--------------- src/painting/painter.rs | 4 +- src/prompt/base.rs | 6 +-- src/prompt/default.rs | 14 +++---- src/prompt/mod.rs | 2 +- 11 files changed, 106 insertions(+), 111 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cbd7ce22..d6d99bf3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,11 +51,6 @@ sqlite = ["rusqlite/bundled", "serde_json"] sqlite-dynlib = ["rusqlite", "serde_json"] system_clipboard = ["arboard"] libc = ["crossterm/libc"] -hx = [] - -[[example]] -name = "helix" -required-features = ["hx"] [[example]] name = "cwd_aware_hinter" diff --git a/examples/demo.rs b/examples/demo.rs index 5e3b65814..d4b8852d0 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -73,7 +73,7 @@ fn main() -> reedline::Result<()> { ]; let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] let cursor_config = CursorConfig { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), @@ -81,7 +81,7 @@ fn main() -> reedline::Result<()> { ..CursorConfig::default() }; - #[cfg(not(feature = "hx"))] + #[cfg(not(feature = "helix"))] let cursor_config = CursorConfig { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 502961ebc..3a7a3e361 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,14 +1,14 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] use crate::edit_mode::hx::word; use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; use crate::prompt::{PromptEditMode, PromptViMode}; use crate::{core_editor::get_local_clipboard, EditCommand}; use std::cmp::{max, min}; use std::ops::{DerefMut, Range}; -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] use unicode_segmentation::UnicodeSegmentation; /// Stateful editor executing changes to the underlying [`LineBuffer`] @@ -25,7 +25,7 @@ pub struct Editor { selection_anchor: Option, selection_mode: Option, edit_mode: PromptEditMode, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] hx_selection: Option, } @@ -41,7 +41,7 @@ impl Default for Editor { selection_anchor: None, selection_mode: None, edit_mode: PromptEditMode::Default, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] hx_selection: None, } } @@ -55,14 +55,14 @@ impl Default for Editor { /// The anchor is where the selection started; the head is where /// the cursor currently sits. Uses gap indexing (left-inclusive, /// right-exclusive), matching Helix semantics. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct HxRange { pub(crate) anchor: usize, pub(crate) head: usize, } -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] impl HxRange { /// Ascending byte range for slicing/rendering. /// Returns (min, max) of anchor and head. @@ -269,39 +269,39 @@ impl Editor { EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxRestartSelection => self.hx_restart_selection(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxClearSelection => self.reset_hx_state(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxEnsureSelection => self.hx_ensure_selection(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSyncCursor => self.hx_sync_cursor(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSyncCursorWithRestart => self.hx_sync_cursor_with_restart(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxWordMotion { target, movement, count, } => self.hx_word_motion(*target, *movement, *count), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxFlipSelection => self.hx_flip_selection(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxMoveToSelectionStart => self.hx_move_to_selection_start(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxMoveToSelectionEnd => self.hx_move_to_selection_end(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSwitchCaseSelection => self.hx_switch_case_selection(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxReplaceSelectionWithChar(c) => self.hx_replace_selection_with_char(*c), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxDeleteSelection => self.hx_delete_selection(), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxExtendSelectionToInsertionPoint => { self.hx_extend_selection_to_insertion_point() } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxShiftSelectionToInsertionPoint => { self.hx_shift_selection_to_insertion_point() } @@ -349,7 +349,7 @@ impl Editor { } /// Disable Helix selection tracking (e.g. when switching away from Helix mode). - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub(crate) fn reset_hx_state(&mut self) { self.hx_selection = None; } @@ -360,7 +360,7 @@ impl Editor { /// /// On an empty buffer, clears the selection instead (no grapheme to /// select). - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub(crate) fn hx_restart_selection(&mut self) { if self.line_buffer.get_buffer().is_empty() { self.hx_selection = None; @@ -376,7 +376,7 @@ impl Editor { /// Ensure an hx selection exists. If one already exists, leave it /// untouched; otherwise create a collapsed selection at the cursor. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub(crate) fn hx_ensure_selection(&mut self) { if self.hx_selection.is_none() { self.hx_restart_selection(); @@ -394,7 +394,7 @@ impl Editor { /// /// Motions start from `cursor()` (the display position), which is /// consistent with Helix's `move_horizontally`. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub(crate) fn hx_sync_cursor(&mut self) { let pos = self.insertion_point(); if let Some(sel) = &mut self.hx_selection { @@ -443,7 +443,7 @@ impl Editor { /// position, so the selection is either strictly forward (motion moved /// right) or strictly backward (motion moved left) — never a flip from /// a prior extended selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub(crate) fn hx_sync_cursor_with_restart(&mut self) { let pos = self.insertion_point(); if let Some(sel) = &mut self.hx_selection { @@ -486,13 +486,13 @@ impl Editor { /// /// Exposes the full [`HxRange`] with anchor/head distinction, unlike /// [`get_selection`](Self::get_selection) which only returns `(min, max)`. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[allow(dead_code)] // available for internal use; currently used in tests pub(crate) fn hx_selection(&self) -> Option<&HxRange> { self.hx_selection.as_ref() } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[cfg(test)] pub(crate) fn set_hx_selection(&mut self, sel: HxRange) { self.hx_selection = Some(sel); @@ -508,7 +508,7 @@ impl Editor { /// If the motion makes no progress (cursor position unchanged), /// the existing selection is preserved — matching Helix behavior /// where a failed motion at end-of-buffer keeps the last selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_word_motion( &mut self, target: crate::enums::WordMotionTarget, @@ -550,7 +550,7 @@ impl Editor { } /// Swap anchor and head of the Helix selection, updating the cursor. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_flip_selection(&mut self) { if let Some(sel) = &mut self.hx_selection { sel.clamp(self.line_buffer.get_buffer()); @@ -561,7 +561,7 @@ impl Editor { } /// Move cursor to the ascending start of the Helix selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_move_to_selection_start(&mut self) { if let Some(sel) = &mut self.hx_selection { sel.clamp(self.line_buffer.get_buffer()); @@ -570,7 +570,7 @@ impl Editor { } /// Move cursor past the ascending end of the Helix selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_move_to_selection_end(&mut self) { if let Some(sel) = &mut self.hx_selection { sel.clamp(self.line_buffer.get_buffer()); @@ -580,7 +580,7 @@ impl Editor { /// Transform the text inside the Helix selection, then update the /// selection to cover the (possibly resized) replacement. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_transform_selection(&mut self, transform: impl FnOnce(&str) -> String) { if let Some(sel) = &mut self.hx_selection { sel.clamp(self.line_buffer.get_buffer()); @@ -606,7 +606,7 @@ impl Editor { } /// Toggle case of every character in the Helix selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_switch_case_selection(&mut self) { self.hx_transform_selection(|selected| { selected @@ -624,7 +624,7 @@ impl Editor { /// Replace every character in the Helix selection with the given char. /// Counts characters (not grapheme clusters) to match Helix behavior. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_replace_selection_with_char(&mut self, c: char) { self.hx_transform_selection(|selected| { let char_count = selected.chars().count(); @@ -634,7 +634,7 @@ impl Editor { /// Delete the Helix selection range without saving to the cut buffer. /// Clears hx_selection afterwards so subsequent commands see no selection. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_delete_selection(&mut self) { if let Some(mut sel) = self.hx_selection.take() { sel.clamp(self.line_buffer.get_buffer()); @@ -650,7 +650,7 @@ impl Editor { /// and head tracks the cursor forward. This normalization ensures /// backward selections are handled correctly: a backward selection /// (anchor > head) is flipped so anchor holds the start before extending. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_extend_selection_to_insertion_point(&mut self) { let pos = self.insertion_point(); if let Some(sel) = &mut self.hx_selection { @@ -666,7 +666,7 @@ impl Editor { /// The cursor sits at the selection start; after the edit it has moved /// forward (insert) or backward (backspace). We compute the delta as /// `insertion_point − selection_start` and apply it to both ends. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] fn hx_shift_selection_to_insertion_point(&mut self) { let pos = self.insertion_point(); if let Some(sel) = &mut self.hx_selection { @@ -701,11 +701,11 @@ impl Editor { /// Check if the editor is currently in Helix edit mode. fn is_hx_mode(&self) -> bool { - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] { matches!(self.edit_mode, PromptEditMode::Helix(_)) } - #[cfg(not(feature = "hx"))] + #[cfg(not(feature = "helix"))] { false } @@ -1106,7 +1106,7 @@ impl Editor { self.system_clipboard.set(cut_slice, ClipboardMode::Normal); self.cut_range(start..end); self.clear_selection(); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.reset_hx_state(); } } @@ -1115,7 +1115,7 @@ impl Editor { if let Some((start, end)) = self.get_selection() { self.cut_range(start..end); self.clear_selection(); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.reset_hx_state(); } } @@ -1150,7 +1150,7 @@ impl Editor { /// Only explicit Helix commands (`CutSelection`, `HxDeleteSelection`) /// should delete the hx-selected text. pub fn get_selection(&self) -> Option<(usize, usize)> { - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] if let Some(sel) = &self.hx_selection { return Some(sel.range()); } @@ -1203,7 +1203,7 @@ impl Editor { if let Some((start, end)) = self.get_selection() { self.line_buffer.clear_range_safe(start..end); self.clear_selection(); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.reset_hx_state(); } } @@ -2643,7 +2643,7 @@ mod test { assert_eq!(quote_result, expected_quote); } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] mod hx_selection_tests { use super::{editor_with, HxRange}; use crate::prompt::{PromptEditMode, PromptHelixMode}; diff --git a/src/core_editor/mod.rs b/src/core_editor/mod.rs index 62af443d6..ca6267f81 100644 --- a/src/core_editor/mod.rs +++ b/src/core_editor/mod.rs @@ -7,6 +7,6 @@ mod line_buffer; pub(crate) use clip_buffer::get_system_clipboard; pub(crate) use clip_buffer::{get_local_clipboard, Clipboard, ClipboardMode}; pub use editor::Editor; -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub(crate) use editor::HxRange; pub use line_buffer::LineBuffer; diff --git a/src/edit_mode/cursors.rs b/src/edit_mode/cursors.rs index 0ca08121d..294c645d4 100644 --- a/src/edit_mode/cursors.rs +++ b/src/edit_mode/cursors.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] use std::collections::HashMap; use crossterm::cursor::SetCursorStyle; @@ -25,11 +25,11 @@ pub struct CursorConfig { /// Cursor shapes for Helix and custom edit modes, keyed by mode name. /// Helix modes use keys [`HX_CURSOR_NORMAL`], [`HX_CURSOR_INSERT`], /// [`HX_CURSOR_SELECT`]. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] pub custom: HashMap, } -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] impl CursorConfig { /// Create a default config with Helix cursor shapes pre-populated. pub fn with_hx_defaults() -> Self { @@ -51,17 +51,17 @@ impl CursorConfig { } /// Cursor config key for Helix Normal mode. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub const HX_CURSOR_NORMAL: &str = "HX_NORMAL"; /// Cursor config key for Helix Insert mode. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub const HX_CURSOR_INSERT: &str = "HX_INSERT"; /// Cursor config key for Helix Select mode. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub const HX_CURSOR_SELECT: &str = "HX_SELECT"; /// Methods available when the `hx` feature is enabled. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] impl CursorConfig { /// Register a cursor shape for a custom edit mode name. /// @@ -86,7 +86,7 @@ mod tests { assert!(config.emacs.is_none()); } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[test] fn hx_defaults_are_set() { let config = CursorConfig::with_hx_defaults(); @@ -104,7 +104,7 @@ mod tests { ); } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[test] fn hx_builders_override_defaults() { let config = CursorConfig::with_hx_defaults() diff --git a/src/engine.rs b/src/engine.rs index 74e969e33..e2c6d95fa 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1474,7 +1474,7 @@ impl Reedline { self.update_buffer_from_history(); self.editor.move_to_start(false); self.editor.move_to_line_end(false); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation); @@ -1510,7 +1510,7 @@ impl Reedline { } self.update_buffer_from_history(); self.editor.move_to_end(false); - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation) @@ -1602,7 +1602,7 @@ impl Reedline { // When the buffer is replaced by history navigation, any Helix-mode // selection is stale (byte offsets from the old buffer). Clear it so // the caller can re-establish a fresh selection after cursor positioning. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] self.editor.reset_hx_state(); match self.history_cursor.get_navigation() { diff --git a/src/enums.rs b/src/enums.rs index 67ff50660..2654f8e7f 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -471,19 +471,19 @@ pub enum EditCommand { }, /// Reset the hx selection anchor+head to the current insertion point - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxRestartSelection, /// Clear hx selection entirely (e.g. when entering Insert mode) - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxClearSelection, /// Ensure an hx selection exists; no-op if one is already set - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxEnsureSelection, /// Adjust insertion point to block-cursor display position after a motion - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxSyncCursor, /// Atomic restart + sync for extending motions (f/t/F/T) in Normal mode. @@ -492,11 +492,11 @@ pub enum EditCommand { /// restarts the anchor at the old cursor and syncs the head to the new /// position. If the cursor did not move, the selection is left /// unchanged — preventing collapse on repeated motions like `t`. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxSyncCursorWithRestart, /// Helix word/WORD motion (w/b/e/W/B/E) - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxWordMotion { /// Which word motion to perform target: WordMotionTarget, @@ -507,43 +507,43 @@ pub enum EditCommand { }, /// Flip the Helix selection (swap anchor and head) - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxFlipSelection, /// Move cursor to the start of the Helix selection (min of anchor, head) - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxMoveToSelectionStart, /// Move cursor to the end of the Helix selection (max of anchor, head) - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxMoveToSelectionEnd, /// Toggle case of entire Helix selection - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxSwitchCaseSelection, /// Replace every grapheme in the Helix selection with the given char - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxReplaceSelectionWithChar(char), /// Delete the Helix selection range without saving to cut buffer - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxDeleteSelection, /// Extend the Helix selection head to the current insertion point. /// Used after `a` (append) so the selection grows as the user types. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxExtendSelectionToInsertionPoint, /// Shift both anchor and head of the Helix selection forward so the /// selection tracks text that was pushed right by an insertion before it. /// Used after `i` (insert) so the selection stays on the same text. - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] HxShiftSelectionToInsertionPoint, } /// Whether a motion resets the selection anchor (Move) or keeps it (Extend). -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Movement { /// Normal mode: anchor follows boundary logic @@ -554,7 +554,7 @@ pub enum Movement { } /// Which word motion to perform. Encodes direction and big/small. -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum WordMotionTarget { /// `w` -- next word start @@ -701,39 +701,39 @@ impl Display for EditCommand { EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxRestartSelection => write!(f, "HxRestartSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxClearSelection => write!(f, "HxClearSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxEnsureSelection => write!(f, "HxEnsureSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSyncCursor => write!(f, "HxSyncCursor"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSyncCursorWithRestart => write!(f, "HxSyncCursorWithRestart"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxWordMotion { target, .. } => { write!(f, "HxWordMotion({:?})", target) } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxFlipSelection => write!(f, "HxFlipSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxMoveToSelectionStart => write!(f, "HxMoveToSelectionStart"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxMoveToSelectionEnd => write!(f, "HxMoveToSelectionEnd"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSwitchCaseSelection => write!(f, "HxSwitchCaseSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxReplaceSelectionWithChar(c) => { write!(f, "HxReplaceSelectionWithChar({c})") } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxDeleteSelection => write!(f, "HxDeleteSelection"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxExtendSelectionToInsertionPoint => { write!(f, "HxExtendSelectionToInsertionPoint") } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxShiftSelectionToInsertionPoint => { write!(f, "HxShiftSelectionToInsertionPoint") } @@ -850,7 +850,7 @@ impl EditCommand { | EditCommand::CopyAroundPair { .. } | EditCommand::CopyTextObject { .. } => EditType::NoOp, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxRestartSelection | EditCommand::HxClearSelection | EditCommand::HxEnsureSelection @@ -859,13 +859,13 @@ impl EditCommand { | EditCommand::HxFlipSelection | EditCommand::HxMoveToSelectionStart | EditCommand::HxMoveToSelectionEnd => EditType::NoOp, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxWordMotion { .. } => EditType::MoveCursor { select: false }, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxSwitchCaseSelection | EditCommand::HxReplaceSelectionWithChar(_) | EditCommand::HxDeleteSelection => EditType::EditText, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] EditCommand::HxExtendSelectionToInsertionPoint | EditCommand::HxShiftSelectionToInsertionPoint => EditType::NoOp, } diff --git a/src/painting/painter.rs b/src/painting/painter.rs index b7beb385e..0a44251b7 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -476,7 +476,7 @@ impl Painter { PromptEditMode::Emacs => shapes.emacs, PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert, PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] PromptEditMode::Helix(ref mode) => { use crate::{HX_CURSOR_INSERT, HX_CURSOR_NORMAL, HX_CURSOR_SELECT}; let key = match mode { @@ -486,7 +486,7 @@ impl Painter { }; shapes.custom.get(key).copied() } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] PromptEditMode::Custom(name) => shapes.custom.get(name.as_str()).copied(), _ => None, }; diff --git a/src/prompt/base.rs b/src/prompt/base.rs index 82b4bc64a..3bc41dcb0 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -56,7 +56,7 @@ pub enum PromptEditMode { Vi(PromptViMode), /// A helix-specific mode - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] Helix(PromptHelixMode), /// A custom mode @@ -75,7 +75,7 @@ pub enum PromptViMode { } /// The helix-specific modes that the prompt can be in -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] #[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] pub enum PromptHelixMode { /// Normal (command) mode @@ -95,7 +95,7 @@ impl Display for PromptEditMode { PromptEditMode::Default => write!(f, "Default"), PromptEditMode::Emacs => write!(f, "Emacs"), PromptEditMode::Vi(_) => write!(f, "Vi_Normal\nVi_Insert"), - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] PromptEditMode::Helix(_) => { write!(f, "Helix_Normal\nHelix_Insert\nHelix_Select") } diff --git a/src/prompt/default.rs b/src/prompt/default.rs index f8953268a..744cba97c 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] use crate::PromptHelixMode; use crate::{Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode}; @@ -14,13 +14,13 @@ pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "〉"; pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; /// The default prompt indicator for helix normal mode -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub static DEFAULT_HX_NORMAL_PROMPT_INDICATOR: &str = "〉"; /// The default prompt indicator for helix insert mode -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub static DEFAULT_HX_INSERT_PROMPT_INDICATOR: &str = ": "; /// The default prompt indicator for helix select mode -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub static DEFAULT_HX_SELECT_PROMPT_INDICATOR: &str = "» "; /// Simple [`Prompt`] displaying a configurable left and a right prompt. @@ -78,7 +78,7 @@ impl Prompt for DefaultPrompt { PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), }, - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] PromptEditMode::Helix(hx_mode) => match hx_mode { PromptHelixMode::Normal => DEFAULT_HX_NORMAL_PROMPT_INDICATOR.into(), PromptHelixMode::Insert => DEFAULT_HX_INSERT_PROMPT_INDICATOR.into(), @@ -174,7 +174,7 @@ mod tests { ); } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[test] fn default_prompt_helix_indicators() { use crate::PromptHelixMode; @@ -194,7 +194,7 @@ mod tests { ); } - #[cfg(feature = "hx")] + #[cfg(feature = "helix")] #[test] fn helix_display_impl() { use crate::PromptHelixMode; diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 572ffb91f..36452c87c 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -1,7 +1,7 @@ mod base; mod default; -#[cfg(feature = "hx")] +#[cfg(feature = "helix")] pub use base::PromptHelixMode; pub use base::{ Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, From 4f4a8f5e568783d8ec69ff11d88a6107b82431d9 Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 10:19:17 +0000 Subject: [PATCH 09/15] route WIP features through existing canonical helix module --- examples/helix.rs | 4 ++-- src/edit_mode/helix.rs | 42 ++++++---------------------------------- src/edit_mode/hx/mod.rs | 10 ++-------- src/edit_mode/hx/word.rs | 14 +++++++------- src/edit_mode/mod.rs | 2 ++ src/engine.rs | 6 +++--- 6 files changed, 22 insertions(+), 56 deletions(-) diff --git a/examples/helix.rs b/examples/helix.rs index 4e797c077..82779881d 100644 --- a/examples/helix.rs +++ b/examples/helix.rs @@ -2,7 +2,7 @@ // cargo run --example helix --features helix // // The current Helix example maps Ctrl-D to exit and uses the default prompt, -// which renders the active custom mode indicator as "(helix)". +// which renders the active Helix mode indicator. use reedline::{DefaultPrompt, Helix, Reedline, Signal}; use std::io; @@ -11,7 +11,7 @@ fn main() -> io::Result<()> { println!("Helix edit mode demo:\nAbort with Ctrl-C"); let prompt = DefaultPrompt::default(); - let mut line_editor = Reedline::create().with_edit_mode(Box::new(Helix)); + let mut line_editor = Reedline::create().with_edit_mode(Box::new(Helix::default())); loop { let sig = line_editor.read_line(&prompt)?; diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index ec03dfc85..0b6b4e308 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -1,56 +1,26 @@ -use crate::{ - enums::{EventStatus, ReedlineEvent, ReedlineRawEvent}, - PromptEditMode, PromptViMode, -}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; - -use super::EditMode; - -/// A minimal custom edit mode example for Helix-style integrations. -#[derive(Default)] -pub struct Helix; - -impl EditMode for Helix { - fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { - match Event::from(event) { - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - }) => ReedlineEvent::CtrlC, - _ => ReedlineEvent::None, - } - } - - fn edit_mode(&self) -> PromptEditMode { - PromptEditMode::Vi(PromptViMode::Normal) - } - - fn handle_mode_specific_event(&mut self, _event: ReedlineEvent) -> EventStatus { - EventStatus::Inapplicable - } -} +pub use super::hx::Helix; #[cfg(test)] mod tests { use super::*; - use crate::PromptViMode; + use crate::{EditMode, PromptEditMode, PromptHelixMode, ReedlineEvent, ReedlineRawEvent}; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; #[test] fn helix_edit_mode_defaults_to_normal_mode() { - let helix_mode = Helix; + let helix_mode = Helix::default(); let edit_mode = helix_mode.edit_mode(); assert!(matches!( edit_mode, - PromptEditMode::Vi(PromptViMode::Normal) + PromptEditMode::Helix(PromptHelixMode::Normal) )); } #[test] fn helix_edit_mode_parses_ctrl_c_event() { - let mut helix_mode = Helix; + let mut helix_mode = Helix::default(); let ctrl_c_raw_event = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( KeyCode::Char('c'), KeyModifiers::CONTROL, diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index c52e404d0..b2aac2f74 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -588,10 +588,7 @@ impl EditMode for Helix { ]), InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::Backspace]), }, - (KeyCode::Delete, _) => { - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![EditCommand::Delete]) - } + (KeyCode::Delete, _) => ReedlineEvent::Edit(vec![EditCommand::Delete]), // Arrow keys / Home / End move the cursor away from the // insert position — clear the selection since byte offsets // become meaningless after arbitrary cursor movement. @@ -970,10 +967,7 @@ mod tests { ..Default::default() }; let event = hx.parse_event(key_press(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!( - event, - ReedlineEvent::Edit(vec![EditCommand::Delete]) - ); + assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Delete])); assert_eq!(hx.insert_style, InsertStyle::Before); } diff --git a/src/edit_mode/hx/word.rs b/src/edit_mode/hx/word.rs index 898640f26..b7e359bb1 100644 --- a/src/edit_mode/hx/word.rs +++ b/src/edit_mode/hx/word.rs @@ -6,13 +6,13 @@ //! - **Punctuation**: everything else that isn't whitespace //! - **Whitespace**: spaces, tabs, etc. //! - //! "Small word" motions (w/b/e) break at any class transition. - //! "Big WORD" motions (W/B/E) only break at whitespace boundaries. - //! - //! Reedline's existing word motions use Unicode word boundary rules. - //! Helix uses a simpler three-class model where `_` is a word character - //! (so `hello_world` is one word) and apostrophes are punctuation - //! (so `don't` is three segments). Line endings are also a distinct class. +//! "Small word" motions (w/b/e) break at any class transition. +//! "Big WORD" motions (W/B/E) only break at whitespace boundaries. +//! +//! Reedline's existing word motions use Unicode word boundary rules. +//! Helix uses a simpler three-class model where `_` is a word character +//! (so `hello_world` is one word) and apostrophes are punctuation +//! (so `don't` is three segments). Line endings are also a distinct class. //! //! Motion functions take an `HxRange` (anchor + head) and return a new //! `HxRange` with both anchor and head adjusted. This keeps boundary diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 05a145c66..3c0f20b9e 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -3,6 +3,8 @@ mod cursors; mod emacs; #[cfg(feature = "helix")] mod helix; +#[cfg(feature = "helix")] +pub(crate) mod hx; mod keybindings; mod vi; diff --git a/src/engine.rs b/src/engine.rs index e2c6d95fa..72afb8a9c 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2295,13 +2295,13 @@ mod tests { #[test] #[cfg(feature = "helix")] fn with_edit_mode_builder_accepts_custom_helix_mode() { - use crate::PromptViMode; + use crate::PromptHelixMode; - let reedline = Reedline::create().with_edit_mode(Box::new(crate::Helix)); + let reedline = Reedline::create().with_edit_mode(Box::new(crate::Helix::default())); assert!(matches!( reedline.prompt_edit_mode(), - PromptEditMode::Vi(PromptViMode::Normal) + PromptEditMode::Helix(PromptHelixMode::Normal) )); } } From c1eca9f2323f307116f7cac37eef9dcecbdfc56a Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 10:19:17 +0000 Subject: [PATCH 10/15] fix clippy error --- src/edit_mode/hx/word.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/edit_mode/hx/word.rs b/src/edit_mode/hx/word.rs index b7e359bb1..dd98998a3 100644 --- a/src/edit_mode/hx/word.rs +++ b/src/edit_mode/hx/word.rs @@ -380,6 +380,9 @@ pub(crate) fn word_move( mod tests { use super::*; + type MotionScenario = (usize, HxRange, HxRange); + type MotionTestCase<'a> = (&'a str, Vec); + // ── Test runner ───────────────────────────────────────────────────── // // Adapted from [helix-core/src/movement.rs](https://github.com/helix-editor/helix/blob/51ec572a27a8c1267afbc07e6c1583585c6363dc/helix-core/src/movement.rs) test infrastructure. @@ -391,7 +394,7 @@ mod tests { fn run_motion_tests( f: fn(&str, &HxRange, usize, bool) -> HxRange, big: bool, - tests: &[(&str, Vec<(usize, HxRange, HxRange)>)], + tests: &[MotionTestCase<'_>], ) { for (sample, scenarios) in tests { for (count, begin, expected) in scenarios { From eb89cb12fffc4a619903d4612b46539b4087dec8 Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 10:57:25 +0000 Subject: [PATCH 11/15] migrate partial feature set to canonical helix.rs module --- src/core_editor/editor.rs | 5 +- src/edit_mode/helix.rs | 389 +++++++++++++++++- src/edit_mode/hx/mod.rs | 824 ++++++++------------------------------ src/edit_mode/mod.rs | 2 +- 4 files changed, 555 insertions(+), 665 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 3a7a3e361..73c83b53e 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,7 +1,10 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; -#[cfg(feature = "helix")] +#[cfg(all(feature = "helix", not(test)))] +#[path = "../edit_mode/hx/word.rs"] +mod word; +#[cfg(all(feature = "helix", test))] use crate::edit_mode::hx::word; use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; use crate::prompt::{PromptEditMode, PromptViMode}; diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index 0b6b4e308..95d83cbd2 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -1,10 +1,212 @@ -pub use super::hx::Helix; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +use crate::{ + edit_mode::EditMode, + enums::{ReedlineEvent, ReedlineRawEvent}, + EditCommand, PromptEditMode, PromptHelixMode, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum HelixMode { + Insert, + #[default] + Normal, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum InsertStyle { + #[default] + Plain, + Before, + After, +} + +/// Minimal Helix-inspired edit mode supporting Normal and Insert states. +#[derive(Default)] +pub struct Helix { + mode: HelixMode, + insert_style: InsertStyle, +} + +impl Helix { + fn enter_insert(&mut self, pre_cmds: Vec) -> ReedlineEvent { + self.mode = HelixMode::Insert; + let mut events = pre_cmds; + events.push(ReedlineEvent::Repaint); + ReedlineEvent::Multiple(events) + } + + #[cfg(test)] + pub(super) fn enter_plain_insert(&mut self) { + self.mode = HelixMode::Insert; + self.insert_style = InsertStyle::Plain; + } +} + +impl EditMode for Helix { + fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { + let Event::Key(KeyEvent { + code, modifiers, .. + }) = event.into() + else { + return ReedlineEvent::None; + }; + + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('c') { + return ReedlineEvent::CtrlC; + } + + match self.mode { + HelixMode::Insert => match (code, modifiers) { + (KeyCode::Esc, _) => { + self.mode = HelixMode::Normal; + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + ReedlineEvent::Edit(vec![EditCommand::Delete]) + } + (KeyCode::Char(c), _) => match self.insert_style { + InsertStyle::Before => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + InsertStyle::After => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), + }, + (KeyCode::Enter, _) => ReedlineEvent::Enter, + (KeyCode::Backspace, _) => match self.insert_style { + InsertStyle::Before => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + InsertStyle::After => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::Backspace]), + }, + (KeyCode::Delete, _) => ReedlineEvent::Edit(vec![EditCommand::Delete]), + (KeyCode::Left, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Right, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveRight { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Home, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::End, _) => { + self.insert_style = InsertStyle::Plain; + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineEnd { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Up, _) => ReedlineEvent::Up, + (KeyCode::Down, _) => ReedlineEvent::Down, + (KeyCode::Tab, _) => ReedlineEvent::None, + _ => ReedlineEvent::None, + }, + HelixMode::Normal => { + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('d') { + return ReedlineEvent::CtrlD; + } + + match code { + KeyCode::Esc => ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]), + KeyCode::Char('i') => { + self.insert_style = InsertStyle::Before; + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + ])]) + } + KeyCode::Char('a') => { + self.insert_style = InsertStyle::After; + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + ])]) + } + KeyCode::Char('I') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineStart { select: false }, + ])]), + KeyCode::Char('A') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineEnd { select: false }, + ])]), + KeyCode::Char('h') => ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxRestartSelection, + ]), + KeyCode::Char('l') => ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Edit(vec![ + EditCommand::MoveRight { select: false }, + EditCommand::HxRestartSelection, + ]), + ]), + KeyCode::Enter => ReedlineEvent::Enter, + _ => ReedlineEvent::None, + } + } + } + } + + fn edit_mode(&self) -> PromptEditMode { + match self.mode { + HelixMode::Insert => PromptEditMode::Helix(PromptHelixMode::Insert), + HelixMode::Normal => PromptEditMode::Helix(PromptHelixMode::Normal), + } + } +} #[cfg(test)] mod tests { use super::*; - use crate::{EditMode, PromptEditMode, PromptHelixMode, ReedlineEvent, ReedlineRawEvent}; - use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use crossterm::event::{KeyEventKind, KeyEventState}; + + fn key_press(code: KeyCode, modifiers: KeyModifiers) -> ReedlineRawEvent { + Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + .try_into() + .unwrap() + } + + fn char_key(c: char) -> ReedlineRawEvent { + key_press(KeyCode::Char(c), KeyModifiers::NONE) + } #[test] fn helix_edit_mode_defaults_to_normal_mode() { @@ -21,14 +223,185 @@ mod tests { #[test] fn helix_edit_mode_parses_ctrl_c_event() { let mut helix_mode = Helix::default(); - let ctrl_c_raw_event = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - ))); assert_eq!( - helix_mode.parse_event(ctrl_c_raw_event.unwrap()), + helix_mode.parse_event(key_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), ReedlineEvent::CtrlC ); } + + #[test] + fn helix_edit_mode_enters_insert_with_i() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(char_key('i')), + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix_mode.mode, HelixMode::Insert); + assert_eq!(helix_mode.insert_style, InsertStyle::Before); + } + + #[test] + fn helix_edit_mode_enters_append_with_a() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(char_key('a')), + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionEnd, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix_mode.mode, HelixMode::Insert); + assert_eq!(helix_mode.insert_style, InsertStyle::After); + } + + #[test] + fn helix_edit_mode_exits_insert_with_escape() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + }; + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)), + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix_mode.mode, HelixMode::Normal); + assert_eq!(helix_mode.insert_style, InsertStyle::Plain); + } + + #[test] + fn helix_edit_mode_ctrl_d_is_delete_in_insert() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Plain, + }; + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), + ReedlineEvent::Edit(vec![EditCommand::Delete]) + ); + } + + #[test] + fn helix_edit_mode_ctrl_d_is_eof_in_normal() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), + ReedlineEvent::CtrlD + ); + } + + #[test] + fn helix_edit_mode_insert_char_tracks_before_mode() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Before, + }; + + assert_eq!( + helix_mode.parse_event(char_key('x')), + ReedlineEvent::Edit(vec![ + EditCommand::InsertChar('x'), + EditCommand::HxShiftSelectionToInsertionPoint, + ]) + ); + } + + #[test] + fn helix_edit_mode_insert_char_tracks_after_mode() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::After, + }; + + assert_eq!( + helix_mode.parse_event(char_key('x')), + ReedlineEvent::Edit(vec![ + EditCommand::InsertChar('x'), + EditCommand::HxExtendSelectionToInsertionPoint, + ]) + ); + } + + #[test] + fn helix_edit_mode_normal_h_restarts_selection() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(char_key('h')), + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxRestartSelection, + ]) + ); + } + + #[test] + fn helix_edit_mode_normal_l_uses_until_found() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(char_key('l')), + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Edit(vec![ + EditCommand::MoveRight { select: false }, + EditCommand::HxRestartSelection, + ]), + ]) + ); + } + + #[test] + fn helix_edit_mode_big_i_enters_insert_at_line_start() { + let mut helix_mode = Helix::default(); + + assert_eq!( + helix_mode.parse_event(char_key('I')), + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![ + EditCommand::HxClearSelection, + EditCommand::MoveToLineStart { select: false }, + ]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix_mode.mode, HelixMode::Insert); + } + + #[test] + fn helix_edit_mode_delete_clears_selection_tracking() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + insert_style: InsertStyle::Before, + }; + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Left, KeyModifiers::NONE)), + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxClearSelection, + ]) + ); + assert_eq!(helix_mode.insert_style, InsertStyle::Plain); + } } diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index b2aac2f74..16c305258 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod word; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use super::helix::Helix as MinimalHelix; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use crate::{ edit_mode::EditMode, @@ -8,18 +9,6 @@ use crate::{ EditCommand, PromptEditMode, PromptHelixMode, }; -/// Helix-style editor modes. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum HelixMode { - /// Insert mode -- typing inserts text. - Insert, - /// Normal (command) mode -- keys are motions/actions. - #[default] - Normal, - /// Visual selection mode -- motions extend the selection. - Select, -} - /// Pending state for multi-key sequences (g_, f/t/F/T + char). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Pending { @@ -39,37 +28,50 @@ enum Pending { Replace, } -/// How the Helix selection should be adjusted as the user types in Insert mode. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum InsertStyle { - /// No selection tracking (entered via `I`, `A`, `c`, or other commands - /// that clear the selection before entering Insert mode). - #[default] - Plain, - /// Entered via `i`: text is inserted *before* the selection, so both - /// anchor and head must be shifted forward by the inserted byte length. - Before, - /// Entered via `a`: text is inserted *after* the selection, so the - /// selection head extends to track the cursor. - After, -} - /// Helix-inspired edit mode for reedline. /// -/// Supports three modes (Insert / Normal / Select) with word-granularity -/// motions and Helix-style selection semantics. +/// Reuses the minimal Insert/Normal implementation from `edit_mode::helix` +/// and layers the extra Select/count/pending/edit commands used by the +/// fuller Helix prototype on top. #[derive(Default)] pub struct Helix { - mode: HelixMode, + base: MinimalHelix, pending: Pending, /// Accumulated numeric prefix (0 = none entered). count: usize, - /// How each `InsertChar` should adjust the Helix selection. - /// Set when entering Insert mode, reset to `Plain` on Esc. - insert_style: InsertStyle, + select_mode: bool, } impl Helix { + fn key_press(code: KeyCode, modifiers: KeyModifiers) -> ReedlineRawEvent { + Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + .try_into() + .unwrap() + } + + fn delegate_to_base(&mut self, code: KeyCode, modifiers: KeyModifiers) -> ReedlineEvent { + self.base.parse_event(Self::key_press(code, modifiers)) + } + + fn in_insert_mode(&self) -> bool { + matches!( + self.base.edit_mode(), + PromptEditMode::Helix(PromptHelixMode::Insert) + ) + } + + fn enter_insert(&mut self, pre_cmds: Vec) -> ReedlineEvent { + self.base.enter_plain_insert(); + let mut events = pre_cmds; + events.push(ReedlineEvent::Repaint); + ReedlineEvent::Multiple(events) + } + /// Wrap motion commands for Select mode: execute motion then apply /// Helix `put_cursor` semantics to extend the selection. /// @@ -91,7 +93,7 @@ impl Helix { /// the target, with direction-flip adjustment when going backward. /// Select mode simply extends the existing selection. fn extending_motion_event(&self, cmds: Vec) -> ReedlineEvent { - if self.mode == HelixMode::Select { + if self.select_mode { return Self::hx_extend(cmds); } let mut v = cmds; @@ -103,7 +105,7 @@ impl Helix { /// - Normal => motion + collapse at new position (1-wide block cursor) /// - Select => motion + extend, anchor stays fn motion_event(&self, cmds: Vec) -> ReedlineEvent { - if self.mode == HelixMode::Select { + if self.select_mode { return Self::hx_extend(cmds); } let mut v = cmds; @@ -111,14 +113,6 @@ impl Helix { ReedlineEvent::Edit(v) } - /// Switch to Insert mode and execute `pre_cmds` before returning Repaint. - fn enter_insert(&mut self, pre_cmds: Vec) -> ReedlineEvent { - self.mode = HelixMode::Insert; - let mut events = pre_cmds; - events.push(ReedlineEvent::Repaint); - ReedlineEvent::Multiple(events) - } - /// Consume the accumulated count prefix, returning at least 1. /// Capped at 100_000 to prevent OOM from absurdly large counts. fn take_count(&mut self) -> usize { @@ -135,7 +129,7 @@ impl Helix { /// that no-progress motions (e.g. at end-of-buffer) can preserve the /// existing selection instead of collapsing it. fn word_motion_event(&self, target: WordMotionTarget, count: usize) -> ReedlineEvent { - let movement = if self.mode == HelixMode::Select { + let movement = if self.select_mode { Movement::Extend } else { Movement::Move @@ -214,27 +208,31 @@ impl Helix { /// the appropriate selection restart/extend commands based on the mode. /// All inner MoveLeft/MoveRight use `select: false` since selection is /// managed through HxRestartSelection/HxSyncCursor. - fn parse_normal_select(&mut self, code: KeyCode, modifiers: KeyModifiers) -> ReedlineEvent { + fn parse_normal_select( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Option { // ── Resolve pending multi-key sequences ───────────────────────── match self.pending { - Pending::Goto => return self.parse_goto(code), + Pending::Goto => return Some(self.parse_goto(code)), Pending::FindForward | Pending::TilForward | Pending::FindBackward | Pending::TilBackward => { let p = self.pending; if let KeyCode::Char(c) = code { - return self.parse_find_char(p, c); + return Some(self.parse_find_char(p, c)); } self.pending = Pending::None; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } Pending::Replace => { if let KeyCode::Char(c) = code { - return self.parse_replace_char(c); + return Some(self.parse_replace_char(c)); } self.pending = Pending::None; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } Pending::None => {} } @@ -246,11 +244,11 @@ impl Helix { .count .saturating_mul(10) .saturating_add((c as usize) - ('0' as usize)); - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('0') if self.count > 0 => { self.count = self.count.saturating_mul(10); - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } _ => {} } @@ -259,27 +257,27 @@ impl Helix { // ── Pending keys: don't consume count, just set pending state ── KeyCode::Char('g') => { self.pending = Pending::Goto; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('f') => { self.pending = Pending::FindForward; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('t') => { self.pending = Pending::TilForward; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('F') => { self.pending = Pending::FindBackward; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('T') => { self.pending = Pending::TilBackward; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } KeyCode::Char('r') => { self.pending = Pending::Replace; - return ReedlineEvent::None; + return Some(ReedlineEvent::None); } _ => {} } @@ -287,7 +285,7 @@ impl Helix { // Try counted motion keys first, then fall through to mode switches // and selection/editing commands which don't use count. if let Some(event) = self.parse_motion(code) { - return event; + return Some(event); } // Everything below ignores count — reset so it doesn't leak. @@ -295,44 +293,33 @@ impl Helix { match code { // ── Mode switches ───────────────────────────────────────── - KeyCode::Char('i') => { - self.insert_style = InsertStyle::Before; - self.enter_insert(vec![ReedlineEvent::Edit(vec![ - EditCommand::HxEnsureSelection, - EditCommand::HxMoveToSelectionStart, - ])]) - } - KeyCode::Char('a') => { - self.insert_style = InsertStyle::After; - self.enter_insert(vec![ReedlineEvent::Edit(vec![ - EditCommand::HxEnsureSelection, - EditCommand::HxMoveToSelectionEnd, - ])]) + KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Char('I') | KeyCode::Char('A') + if self.select_mode => + { + self.select_mode = false; + Some(self.delegate_to_base(code, modifiers)) } - KeyCode::Char('I') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ - EditCommand::HxClearSelection, - EditCommand::MoveToLineStart { select: false }, - ])]), - KeyCode::Char('A') => self.enter_insert(vec![ReedlineEvent::Edit(vec![ - EditCommand::HxClearSelection, - EditCommand::MoveToLineEnd { select: false }, - ])]), - KeyCode::Char('v') => match self.mode { - HelixMode::Normal => { - self.mode = HelixMode::Select; - ReedlineEvent::Repaint - } - HelixMode::Select => { - self.mode = HelixMode::Normal; - ReedlineEvent::Multiple(vec![ + KeyCode::Char('v') => { + if self.select_mode { + self.select_mode = false; + Some(ReedlineEvent::Multiple(vec![ ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), ReedlineEvent::Repaint, - ]) + ])) + } else { + self.select_mode = true; + Some(ReedlineEvent::Repaint) + } + } + KeyCode::Char(';') => Some(ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection])), + _ => { + let event = self.parse_non_counted(code, modifiers); + if self.select_mode || !matches!(event, ReedlineEvent::None) { + Some(event) + } else { + None } - HelixMode::Insert => ReedlineEvent::None, - }, - KeyCode::Char(';') => ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), - _ => self.parse_non_counted(code, modifiers), + } } } @@ -344,8 +331,12 @@ impl Helix { let event = match code { // ── Basic motions (h/l/j/k) ─────────────────────────── KeyCode::Char('h') => { + if !self.select_mode && self.count == 0 { + return None; + } + let count = self.take_count(); - if self.mode == HelixMode::Select { + if self.select_mode { let motion = Self::hx_extend(vec![EditCommand::MoveLeft { select: false }]); if count <= 1 { motion @@ -360,6 +351,10 @@ impl Helix { } } KeyCode::Char('l') => { + if !self.select_mode && self.count == 0 { + return None; + } + let count = self.take_count(); let motion = ReedlineEvent::UntilFound(vec![ ReedlineEvent::HistoryHintComplete, @@ -540,121 +535,42 @@ impl EditMode for Helix { return ReedlineEvent::None; }; - // ── Global bindings ─────────────────────────────────────────── - // Ctrl+C is always interrupt. Ctrl+D is EOF in Normal/Select but - // delete-forward in Insert (handled below). + if self.in_insert_mode() { + return self.delegate_to_base(code, modifiers); + } + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('c') { return ReedlineEvent::CtrlC; } - match self.mode { - // ── Insert mode ─────────────────────────────────────────── - HelixMode::Insert => match (code, modifiers) { - (KeyCode::Esc, _) => { - self.mode = HelixMode::Normal; - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Esc, - // Step back so the block cursor lands ON the last typed character - // (Insert cursor is between chars; Normal cursor is on a char). - ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), - ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), - ReedlineEvent::Repaint, - ]) - } - (KeyCode::Char('d'), KeyModifiers::CONTROL) => { - ReedlineEvent::Edit(vec![EditCommand::Delete]) - } - (KeyCode::Char(c), _) => match self.insert_style { - InsertStyle::Before => ReedlineEvent::Edit(vec![ - EditCommand::InsertChar(c), - EditCommand::HxShiftSelectionToInsertionPoint, - ]), - InsertStyle::After => ReedlineEvent::Edit(vec![ - EditCommand::InsertChar(c), - EditCommand::HxExtendSelectionToInsertionPoint, - ]), - InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), - }, - (KeyCode::Enter, _) => ReedlineEvent::Enter, - (KeyCode::Backspace, _) => match self.insert_style { - InsertStyle::Before => ReedlineEvent::Edit(vec![ - EditCommand::Backspace, - EditCommand::HxShiftSelectionToInsertionPoint, - ]), - InsertStyle::After => ReedlineEvent::Edit(vec![ - EditCommand::Backspace, - EditCommand::HxExtendSelectionToInsertionPoint, - ]), - InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::Backspace]), - }, - (KeyCode::Delete, _) => ReedlineEvent::Edit(vec![EditCommand::Delete]), - // Arrow keys / Home / End move the cursor away from the - // insert position — clear the selection since byte offsets - // become meaningless after arbitrary cursor movement. - (KeyCode::Left, _) => { - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![ - EditCommand::MoveLeft { select: false }, - EditCommand::HxClearSelection, - ]) - } - (KeyCode::Right, _) => { - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![ - EditCommand::MoveRight { select: false }, - EditCommand::HxClearSelection, - ]) - } - (KeyCode::Home, _) => { - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![ - EditCommand::MoveToLineStart { select: false }, - EditCommand::HxClearSelection, - ]) - } - (KeyCode::End, _) => { - self.insert_style = InsertStyle::Plain; - ReedlineEvent::Edit(vec![ - EditCommand::MoveToLineEnd { select: false }, - EditCommand::HxClearSelection, - ]) - } - (KeyCode::Up, _) => ReedlineEvent::Up, - (KeyCode::Down, _) => ReedlineEvent::Down, - (KeyCode::Tab, _) => ReedlineEvent::None, - _ => ReedlineEvent::None, - }, - - // ── Normal / Select mode ────────────────────────────────── - HelixMode::Normal | HelixMode::Select => { - if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('d') { - return ReedlineEvent::CtrlD; - } - match code { - KeyCode::Esc => { - self.mode = HelixMode::Normal; - self.pending = Pending::None; - self.count = 0; - // Collapse selection so stale extended selection doesn't persist. - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Esc, - ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), - ReedlineEvent::Repaint, - ]) - } - _ => self.parse_normal_select(code, modifiers), - } - } + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('d') { + return ReedlineEvent::CtrlD; + } + + if code == KeyCode::Esc { + self.select_mode = false; + self.pending = Pending::None; + self.count = 0; + return ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Repaint, + ]); + } + + if let Some(event) = self.parse_normal_select(code, modifiers) { + return event; } + + self.delegate_to_base(code, modifiers) } /// Return the current prompt edit mode indicator. fn edit_mode(&self) -> PromptEditMode { - match self.mode { - HelixMode::Insert => PromptEditMode::Helix(PromptHelixMode::Insert), - HelixMode::Normal => PromptEditMode::Helix(PromptHelixMode::Normal), - HelixMode::Select => PromptEditMode::Helix(PromptHelixMode::Select), + if self.select_mode { + PromptEditMode::Helix(PromptHelixMode::Select) + } else { + self.base.edit_mode() } } } @@ -688,191 +604,44 @@ mod tests { } } - // ── parse_event unit tests ────────────────────────────────────────── - - #[test] - fn ctrl_c_works_in_all_modes() { - for initial_mode in [HelixMode::Insert, HelixMode::Normal, HelixMode::Select] { - let mut hx = Helix { - mode: initial_mode, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert_eq!(event, ReedlineEvent::CtrlC); - } - } - - #[test] - fn ctrl_d_eof_in_normal_and_select() { - for initial_mode in [HelixMode::Normal, HelixMode::Select] { - let mut hx = Helix { - mode: initial_mode, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); - assert_eq!(event, ReedlineEvent::CtrlD); + fn select_hx() -> Helix { + Helix { + select_mode: true, + ..Default::default() } } - #[test] - fn ctrl_d_deletes_forward_in_insert() { - let mut hx = Helix { - mode: HelixMode::Insert, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); - assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Delete])); - } + // ── parse_event unit tests ────────────────────────────────────────── #[test] - fn esc_from_insert_enters_normal() { - let mut hx = Helix { - mode: HelixMode::Insert, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!( - event, - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Esc, - ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), - ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), - ReedlineEvent::Repaint, - ]) - ); - assert_eq!(hx.mode, HelixMode::Normal); - } + fn ctrl_d_eof_in_select_mode() { + let mut hx = select_hx(); - #[test] - fn i_from_normal_enters_insert() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; - let event = hx.parse_event(char_key('i')); - assert_eq!( - event, - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![ - EditCommand::HxEnsureSelection, - EditCommand::HxMoveToSelectionStart, - ]), - ReedlineEvent::Repaint, - ]) - ); - assert_eq!(hx.mode, HelixMode::Insert); - assert_eq!(hx.insert_style, InsertStyle::Before); - } + let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); - #[test] - fn a_from_normal_enters_insert_after_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; - let event = hx.parse_event(char_key('a')); - assert_eq!( - event, - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![ - EditCommand::HxEnsureSelection, - EditCommand::HxMoveToSelectionEnd, - ]), - ReedlineEvent::Repaint, - ]) - ); - assert_eq!(hx.mode, HelixMode::Insert); - assert_eq!(hx.insert_style, InsertStyle::After); + assert_eq!(event, ReedlineEvent::CtrlD); } #[test] fn v_toggles_select_mode() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); // Normal -> Select let event = hx.parse_event(char_key('v')); assert_eq!(event, ReedlineEvent::Repaint); - assert_eq!(hx.mode, HelixMode::Select); + assert!(hx.select_mode); // Select -> Normal (with selection restart) let event = hx.parse_event(char_key('v')); assert!(matches!(event, ReedlineEvent::Multiple(_))); - assert_eq!(hx.mode, HelixMode::Normal); - } - - #[test] - fn insert_char_in_insert_mode() { - let mut hx = Helix { - mode: HelixMode::Insert, - ..Default::default() - }; - let event = hx.parse_event(char_key('x')); - assert_eq!( - event, - ReedlineEvent::Edit(vec![EditCommand::InsertChar('x')]) - ); - } - - #[test] - fn insert_char_in_append_mode_extends_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::After, - ..Default::default() - }; - let event = hx.parse_event(char_key('x')); - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::InsertChar('x'), - EditCommand::HxExtendSelectionToInsertionPoint, - ]) - ); + assert!(!hx.select_mode); } #[test] - fn insert_char_in_before_mode_shifts_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::Before, - ..Default::default() - }; - let event = hx.parse_event(char_key('x')); - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::InsertChar('x'), - EditCommand::HxShiftSelectionToInsertionPoint, - ]) - ); - } + fn h_in_select_extends_without_restart() { + let mut hx = select_hx(); - #[test] - fn h_in_normal_produces_motion_with_collapse() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; let event = hx.parse_event(char_key('h')); - // Normal mode: move then collapse (no visible selection). - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::MoveLeft { select: false }, - EditCommand::HxRestartSelection, - ]) - ); - } - #[test] - fn h_in_select_extends_without_restart() { - let mut hx = Helix { - mode: HelixMode::Select, - ..Default::default() - }; - let event = hx.parse_event(char_key('h')); assert_eq!( event, ReedlineEvent::Edit(vec![ @@ -884,11 +653,10 @@ mod tests { #[test] fn w_in_normal_produces_word_motion() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); + let event = hx.parse_event(char_key('w')); + // Word motions handle their own selection (restart is internal to // hx_word_motion so no-progress motions can preserve the selection). assert_eq!( @@ -901,100 +669,9 @@ mod tests { ); } - #[test] - fn enter_in_insert_mode() { - let mut hx = Helix { - mode: HelixMode::Insert, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(event, ReedlineEvent::Enter); - } - - #[test] - fn backspace_plain_insert_is_bare() { - let mut hx = Helix { - mode: HelixMode::Insert, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Backspace])); - assert_eq!(hx.insert_style, InsertStyle::Plain); - } - - #[test] - fn backspace_in_a_mode_extends_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::After, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::Backspace, - EditCommand::HxExtendSelectionToInsertionPoint, - ]) - ); - // insert_style stays After — selection is still tracked. - assert_eq!(hx.insert_style, InsertStyle::After); - } - - #[test] - fn backspace_in_i_mode_shifts_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::Before, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::Backspace, - EditCommand::HxShiftSelectionToInsertionPoint, - ]) - ); - assert_eq!(hx.insert_style, InsertStyle::Before); - } - - #[test] - fn delete_in_insert_preserves_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::Before, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!(event, ReedlineEvent::Edit(vec![EditCommand::Delete])); - assert_eq!(hx.insert_style, InsertStyle::Before); - } - - #[test] - fn arrow_left_in_insert_clears_selection() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::After, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!( - event, - ReedlineEvent::Edit(vec![ - EditCommand::MoveLeft { select: false }, - EditCommand::HxClearSelection, - ]) - ); - assert_eq!(hx.insert_style, InsertStyle::Plain); - } - #[test] fn count_not_consumed_by_editing_commands() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); // Press '3' then 'd' — count should be discarded, not affect deletion. hx.parse_event(char_key('3')); assert_eq!(hx.count, 3); @@ -1013,24 +690,16 @@ mod tests { #[test] fn count_applies_to_j_k() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('3')); let event = hx.parse_event(char_key('j')); assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 3)); } #[test] - fn edit_mode_returns_correct_prompt() { - let mut hx = Helix::default(); - assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Normal)); + fn edit_mode_reports_select_prompt() { + let hx = select_hx(); - hx.mode = HelixMode::Insert; - assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Insert)); - - hx.mode = HelixMode::Select; assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Select)); } @@ -1287,10 +956,7 @@ mod tests { #[test] fn count_prefix_repeats_h_motion_normal() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); // Press '3' then 'h' — Normal mode batches moves + one restart. let event = hx.parse_event(char_key('3')); assert_eq!(event, ReedlineEvent::None); @@ -1308,10 +974,7 @@ mod tests { #[test] fn count_prefix_repeats_h_motion_select() { - let mut hx = Helix { - mode: HelixMode::Select, - ..Default::default() - }; + let mut hx = select_hx(); // Press '3' then 'h' — Select mode needs per-step sync. hx.parse_event(char_key('3')); let event = hx.parse_event(char_key('h')); @@ -1320,10 +983,7 @@ mod tests { #[test] fn count_prefix_passes_to_word_motion() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('2')); let event = hx.parse_event(char_key('w')); assert_eq!( @@ -1338,10 +998,7 @@ mod tests { #[test] fn count_zero_extends_digit() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('1')); hx.parse_event(char_key('0')); let event = hx.parse_event(char_key('l')); @@ -1353,10 +1010,7 @@ mod tests { #[test] fn invalid_key_after_goto_cancels() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('z')); // invalid goto target assert_eq!(event, ReedlineEvent::None); @@ -1365,10 +1019,7 @@ mod tests { #[test] fn invalid_key_after_find_cancels() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('f')); // Esc is handled by the top-level Normal/Select match before pending // resolution, so it resets everything (mode, pending, count). @@ -1379,10 +1030,7 @@ mod tests { #[test] fn goto_gg_moves_to_start() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('g')); assert_eq!( @@ -1396,10 +1044,7 @@ mod tests { #[test] fn goto_ge_is_unbound() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('e')); // ge is not yet implemented (needs PrevWordEnd motion target). @@ -1408,10 +1053,7 @@ mod tests { #[test] fn f_char_produces_extending_motion() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('f')); let event = hx.parse_event(char_key('x')); assert_eq!( @@ -1426,56 +1068,11 @@ mod tests { ); } - // ── I/A mode switch tests ───────────────────────────────────────── - - #[test] - fn big_i_enters_insert_at_line_start() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; - let event = hx.parse_event(char_key('I')); - assert_eq!( - event, - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![ - EditCommand::HxClearSelection, - EditCommand::MoveToLineStart { select: false }, - ]), - ReedlineEvent::Repaint, - ]) - ); - assert_eq!(hx.mode, HelixMode::Insert); - } - - #[test] - fn big_a_enters_insert_at_line_end() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; - let event = hx.parse_event(char_key('A')); - assert_eq!( - event, - ReedlineEvent::Multiple(vec![ - ReedlineEvent::Edit(vec![ - EditCommand::HxClearSelection, - EditCommand::MoveToLineEnd { select: false }, - ]), - ReedlineEvent::Repaint, - ]) - ); - assert_eq!(hx.mode, HelixMode::Insert); - } - // ── Edit command tests ──────────────────────────────────────────── #[test] fn d_deletes_with_yank() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('d')); assert_eq!( event, @@ -1489,10 +1086,7 @@ mod tests { #[test] fn alt_d_deletes_without_yank() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::ALT)); assert_eq!( event, @@ -1506,10 +1100,7 @@ mod tests { #[test] fn c_changes_with_yank() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('c')); assert_eq!( event, @@ -1522,15 +1113,12 @@ mod tests { ReedlineEvent::Repaint, ]) ); - assert_eq!(hx.mode, HelixMode::Insert); + assert!(matches!(edit_mode_hx(&hx), PromptHelixMode::Insert)); } #[test] fn y_yanks_preserving_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('y')); assert_eq!( event, @@ -1543,10 +1131,7 @@ mod tests { #[test] fn p_pastes_after_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('p')); assert_eq!( event, @@ -1562,10 +1147,7 @@ mod tests { #[test] fn big_p_pastes_before_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('P')); assert_eq!( event, @@ -1583,10 +1165,7 @@ mod tests { #[test] fn semicolon_restarts_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key(';')); assert_eq!( event, @@ -1596,10 +1175,7 @@ mod tests { #[test] fn percent_selects_entire_buffer() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('%')); assert_eq!( event, @@ -1614,10 +1190,7 @@ mod tests { #[test] fn x_selects_entire_line() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('x')); assert_eq!( event, @@ -1632,10 +1205,7 @@ mod tests { #[test] fn o_flips_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('o')); assert_eq!( event, @@ -1647,10 +1217,7 @@ mod tests { #[test] fn u_undoes() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('u')); assert_eq!( event, @@ -1660,10 +1227,7 @@ mod tests { #[test] fn big_u_redoes() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('U')); assert_eq!( event, @@ -1675,10 +1239,7 @@ mod tests { #[test] fn r_char_replaces_selection() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('r')); let event = hx.parse_event(char_key('z')); assert_eq!( @@ -1694,10 +1255,7 @@ mod tests { #[test] fn tilde_switches_case() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); let event = hx.parse_event(char_key('~')); assert_eq!( event, @@ -1712,39 +1270,21 @@ mod tests { #[test] fn esc_in_normal_resets_pending_and_count() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('5')); // count hx.parse_event(char_key('g')); // pending goto let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(event, ReedlineEvent::Multiple(_))); - assert_eq!(hx.mode, HelixMode::Normal); + assert!(!hx.select_mode); assert_eq!(hx.pending, Pending::None); assert_eq!(hx.count, 0); } - // ── Enter in Normal mode submits ────────────────────────────────── - - #[test] - fn enter_in_normal_submits() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; - let event = hx.parse_event(key_press(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(event, ReedlineEvent::Enter); - } - // ── F/T backward find/til tests ───────────────────────────────────── #[test] fn big_f_char_produces_extending_motion() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('F')); let event = hx.parse_event(char_key('a')); assert_eq!( @@ -1761,10 +1301,7 @@ mod tests { #[test] fn big_t_char_produces_extending_motion() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('T')); let event = hx.parse_event(char_key('a')); assert_eq!( @@ -1781,10 +1318,7 @@ mod tests { #[test] fn count_with_f_produces_multiple_events() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); hx.parse_event(char_key('2')); hx.parse_event(char_key('f')); let event = hx.parse_event(char_key('x')); @@ -1830,10 +1364,7 @@ mod tests { #[test] fn count_zero_at_start_is_not_count() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); // '0' at start should not be a count digit (count is 0, so it's not > 0) let event = hx.parse_event(char_key('0')); // Falls through to match code block (no motion bound to '0') @@ -1843,10 +1374,7 @@ mod tests { #[test] fn large_count_on_short_buffer_does_not_panic() { - let mut hx = Helix { - mode: HelixMode::Normal, - ..Default::default() - }; + let mut hx = Helix::default(); // Enter count 100 hx.parse_event(char_key('1')); hx.parse_event(char_key('0')); @@ -1885,20 +1413,6 @@ mod tests { assert_sel(&b, "über coo[l]", "über ]cool["); } - // ── Esc from Insert resets insert_style ───────────────────────────── - - #[test] - fn esc_from_insert_resets_insert_style() { - let mut hx = Helix { - mode: HelixMode::Insert, - insert_style: InsertStyle::After, - ..Default::default() - }; - hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(hx.mode, HelixMode::Normal); - assert_eq!(hx.insert_style, InsertStyle::Plain); - } - // ── f/t extending motion integration tests ────────────────────────── #[test] diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 3c0f20b9e..81f3e91fd 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -3,7 +3,7 @@ mod cursors; mod emacs; #[cfg(feature = "helix")] mod helix; -#[cfg(feature = "helix")] +#[cfg(all(feature = "helix", test))] pub(crate) mod hx; mod keybindings; mod vi; From 60426f23a4a8c268f5c0f0c536e8e4dde7e9a5be Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 11:33:32 +0000 Subject: [PATCH 12/15] rename InsertStyle per PR discussion --- src/core_editor/editor.rs | 4 +-- src/edit_mode/helix.rs | 72 +++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 73c83b53e..9e21a4de4 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -3169,7 +3169,7 @@ mod test { assert_eq!(sel.head, 3); } - // ── InsertStyle::Before (i) selection tracking ────────────── + // ── SelectionAdjustment::Shifting (`i`) tracking ─────────── #[test] fn i_mode_shift_tracks_insertion() { @@ -3251,7 +3251,7 @@ mod test { assert_eq!(editor.get_buffer(), "hxello"); } - // ── InsertStyle::After (a) selection tracking ─────────────── + // ── SelectionAdjustment::Anchored (`a`) tracking ─────────── #[test] fn a_mode_extend_tracks_insertion() { diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index 95d83cbd2..f63a60a0f 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -13,19 +13,17 @@ enum HelixMode { Normal, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum InsertStyle { - #[default] - Plain, - Before, - After, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectionAdjustment { + Shifting, + Anchored, } /// Minimal Helix-inspired edit mode supporting Normal and Insert states. #[derive(Default)] pub struct Helix { mode: HelixMode, - insert_style: InsertStyle, + selection_adjustment: Option, } impl Helix { @@ -39,7 +37,7 @@ impl Helix { #[cfg(test)] pub(super) fn enter_plain_insert(&mut self) { self.mode = HelixMode::Insert; - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; } } @@ -60,7 +58,7 @@ impl EditMode for Helix { HelixMode::Insert => match (code, modifiers) { (KeyCode::Esc, _) => { self.mode = HelixMode::Normal; - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; ReedlineEvent::Multiple(vec![ ReedlineEvent::Esc, ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), @@ -71,53 +69,53 @@ impl EditMode for Helix { (KeyCode::Char('d'), KeyModifiers::CONTROL) => { ReedlineEvent::Edit(vec![EditCommand::Delete]) } - (KeyCode::Char(c), _) => match self.insert_style { - InsertStyle::Before => ReedlineEvent::Edit(vec![ + (KeyCode::Char(c), _) => match self.selection_adjustment { + Some(SelectionAdjustment::Shifting) => ReedlineEvent::Edit(vec![ EditCommand::InsertChar(c), EditCommand::HxShiftSelectionToInsertionPoint, ]), - InsertStyle::After => ReedlineEvent::Edit(vec![ + Some(SelectionAdjustment::Anchored) => ReedlineEvent::Edit(vec![ EditCommand::InsertChar(c), EditCommand::HxExtendSelectionToInsertionPoint, ]), - InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), + None => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), }, (KeyCode::Enter, _) => ReedlineEvent::Enter, - (KeyCode::Backspace, _) => match self.insert_style { - InsertStyle::Before => ReedlineEvent::Edit(vec![ + (KeyCode::Backspace, _) => match self.selection_adjustment { + Some(SelectionAdjustment::Shifting) => ReedlineEvent::Edit(vec![ EditCommand::Backspace, EditCommand::HxShiftSelectionToInsertionPoint, ]), - InsertStyle::After => ReedlineEvent::Edit(vec![ + Some(SelectionAdjustment::Anchored) => ReedlineEvent::Edit(vec![ EditCommand::Backspace, EditCommand::HxExtendSelectionToInsertionPoint, ]), - InsertStyle::Plain => ReedlineEvent::Edit(vec![EditCommand::Backspace]), + None => ReedlineEvent::Edit(vec![EditCommand::Backspace]), }, (KeyCode::Delete, _) => ReedlineEvent::Edit(vec![EditCommand::Delete]), (KeyCode::Left, _) => { - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; ReedlineEvent::Edit(vec![ EditCommand::MoveLeft { select: false }, EditCommand::HxClearSelection, ]) } (KeyCode::Right, _) => { - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; ReedlineEvent::Edit(vec![ EditCommand::MoveRight { select: false }, EditCommand::HxClearSelection, ]) } (KeyCode::Home, _) => { - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; ReedlineEvent::Edit(vec![ EditCommand::MoveToLineStart { select: false }, EditCommand::HxClearSelection, ]) } (KeyCode::End, _) => { - self.insert_style = InsertStyle::Plain; + self.selection_adjustment = None; ReedlineEvent::Edit(vec![ EditCommand::MoveToLineEnd { select: false }, EditCommand::HxClearSelection, @@ -140,14 +138,14 @@ impl EditMode for Helix { ReedlineEvent::Repaint, ]), KeyCode::Char('i') => { - self.insert_style = InsertStyle::Before; + self.selection_adjustment = Some(SelectionAdjustment::Shifting); self.enter_insert(vec![ReedlineEvent::Edit(vec![ EditCommand::HxEnsureSelection, EditCommand::HxMoveToSelectionStart, ])]) } KeyCode::Char('a') => { - self.insert_style = InsertStyle::After; + self.selection_adjustment = Some(SelectionAdjustment::Anchored); self.enter_insert(vec![ReedlineEvent::Edit(vec![ EditCommand::HxEnsureSelection, EditCommand::HxMoveToSelectionEnd, @@ -245,7 +243,10 @@ mod tests { ]) ); assert_eq!(helix_mode.mode, HelixMode::Insert); - assert_eq!(helix_mode.insert_style, InsertStyle::Before); + assert_eq!( + helix_mode.selection_adjustment, + Some(SelectionAdjustment::Shifting) + ); } #[test] @@ -263,14 +264,17 @@ mod tests { ]) ); assert_eq!(helix_mode.mode, HelixMode::Insert); - assert_eq!(helix_mode.insert_style, InsertStyle::After); + assert_eq!( + helix_mode.selection_adjustment, + Some(SelectionAdjustment::Anchored) + ); } #[test] fn helix_edit_mode_exits_insert_with_escape() { let mut helix_mode = Helix { mode: HelixMode::Insert, - insert_style: InsertStyle::After, + selection_adjustment: Some(SelectionAdjustment::Anchored), }; assert_eq!( @@ -283,14 +287,14 @@ mod tests { ]) ); assert_eq!(helix_mode.mode, HelixMode::Normal); - assert_eq!(helix_mode.insert_style, InsertStyle::Plain); + assert_eq!(helix_mode.selection_adjustment, None); } #[test] fn helix_edit_mode_ctrl_d_is_delete_in_insert() { let mut helix_mode = Helix { mode: HelixMode::Insert, - insert_style: InsertStyle::Plain, + selection_adjustment: None, }; assert_eq!( @@ -310,10 +314,10 @@ mod tests { } #[test] - fn helix_edit_mode_insert_char_tracks_before_mode() { + fn helix_edit_mode_insert_char_uses_shifting_adjustment() { let mut helix_mode = Helix { mode: HelixMode::Insert, - insert_style: InsertStyle::Before, + selection_adjustment: Some(SelectionAdjustment::Shifting), }; assert_eq!( @@ -326,10 +330,10 @@ mod tests { } #[test] - fn helix_edit_mode_insert_char_tracks_after_mode() { + fn helix_edit_mode_insert_char_uses_anchored_adjustment() { let mut helix_mode = Helix { mode: HelixMode::Insert, - insert_style: InsertStyle::After, + selection_adjustment: Some(SelectionAdjustment::Anchored), }; assert_eq!( @@ -392,7 +396,7 @@ mod tests { fn helix_edit_mode_delete_clears_selection_tracking() { let mut helix_mode = Helix { mode: HelixMode::Insert, - insert_style: InsertStyle::Before, + selection_adjustment: Some(SelectionAdjustment::Shifting), }; assert_eq!( @@ -402,6 +406,6 @@ mod tests { EditCommand::HxClearSelection, ]) ); - assert_eq!(helix_mode.insert_style, InsertStyle::Plain); + assert_eq!(helix_mode.selection_adjustment, None); } } From 7f5ea52516b22a23ad4ae6d4c89d0d18745fb020 Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 11:42:36 +0000 Subject: [PATCH 13/15] reroute modules so that example uses full feature set --- examples/helix.rs | 7 ++++--- src/edit_mode/helix.rs | 3 +-- src/edit_mode/hx/mod.rs | 1 + src/edit_mode/mod.rs | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/helix.rs b/examples/helix.rs index 82779881d..db01fe087 100644 --- a/examples/helix.rs +++ b/examples/helix.rs @@ -1,8 +1,9 @@ -// Create a reedline object with the experimental Helix edit mode. +// Create a reedline object with the full experimental Helix edit mode. // cargo run --example helix --features helix // -// The current Helix example maps Ctrl-D to exit and uses the default prompt, -// which renders the active Helix mode indicator. +// This example uses the public Helix mode exported by the crate, including +// Select mode and the extended normal-mode command set. The default prompt +// renders the active Helix mode indicator. use reedline::{DefaultPrompt, Helix, Reedline, Signal}; use std::io; diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index f63a60a0f..d84ec155f 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -34,8 +34,7 @@ impl Helix { ReedlineEvent::Multiple(events) } - #[cfg(test)] - pub(super) fn enter_plain_insert(&mut self) { + pub(crate) fn enter_plain_insert(&mut self) { self.mode = HelixMode::Insert; self.selection_adjustment = None; } diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index 16c305258..a0f0db5f7 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -1,3 +1,4 @@ +#[cfg(test)] pub(crate) mod word; use super::helix::Helix as MinimalHelix; diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 81f3e91fd..4910da5c3 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -3,7 +3,7 @@ mod cursors; mod emacs; #[cfg(feature = "helix")] mod helix; -#[cfg(all(feature = "helix", test))] +#[cfg(feature = "helix")] pub(crate) mod hx; mod keybindings; mod vi; @@ -14,6 +14,6 @@ pub use cursors::CursorConfig; pub use cursors::{HX_CURSOR_INSERT, HX_CURSOR_NORMAL, HX_CURSOR_SELECT}; pub use emacs::{default_emacs_keybindings, Emacs}; #[cfg(feature = "helix")] -pub use helix::Helix; +pub use hx::Helix; pub use keybindings::Keybindings; pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; From d6bfb493afdf02c64738a3fa9ef8d0fcd94301b2 Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 11:49:54 +0000 Subject: [PATCH 14/15] change default mode to Insert mode --- src/edit_mode/helix.rs | 28 +++++++++------ src/edit_mode/hx/mod.rs | 80 ++++++++++++++++++++++++----------------- src/engine.rs | 2 +- 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index d84ec155f..16d172d8b 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -8,8 +8,8 @@ use crate::{ #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum HelixMode { - Insert, #[default] + Insert, Normal, } @@ -27,6 +27,14 @@ pub struct Helix { } impl Helix { + #[cfg(test)] + pub(crate) fn normal() -> Self { + Self { + mode: HelixMode::Normal, + selection_adjustment: None, + } + } + fn enter_insert(&mut self, pre_cmds: Vec) -> ReedlineEvent { self.mode = HelixMode::Insert; let mut events = pre_cmds; @@ -206,20 +214,20 @@ mod tests { } #[test] - fn helix_edit_mode_defaults_to_normal_mode() { + fn helix_edit_mode_defaults_to_insert_mode() { let helix_mode = Helix::default(); let edit_mode = helix_mode.edit_mode(); assert!(matches!( edit_mode, - PromptEditMode::Helix(PromptHelixMode::Normal) + PromptEditMode::Helix(PromptHelixMode::Insert) )); } #[test] fn helix_edit_mode_parses_ctrl_c_event() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(key_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), @@ -229,7 +237,7 @@ mod tests { #[test] fn helix_edit_mode_enters_insert_with_i() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(char_key('i')), @@ -250,7 +258,7 @@ mod tests { #[test] fn helix_edit_mode_enters_append_with_a() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(char_key('a')), @@ -304,7 +312,7 @@ mod tests { #[test] fn helix_edit_mode_ctrl_d_is_eof_in_normal() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), @@ -346,7 +354,7 @@ mod tests { #[test] fn helix_edit_mode_normal_h_restarts_selection() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(char_key('h')), @@ -359,7 +367,7 @@ mod tests { #[test] fn helix_edit_mode_normal_l_uses_until_found() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(char_key('l')), @@ -376,7 +384,7 @@ mod tests { #[test] fn helix_edit_mode_big_i_enters_insert_at_line_start() { - let mut helix_mode = Helix::default(); + let mut helix_mode = Helix::normal(); assert_eq!( helix_mode.parse_event(char_key('I')), diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs index a0f0db5f7..9bf1216e7 100644 --- a/src/edit_mode/hx/mod.rs +++ b/src/edit_mode/hx/mod.rs @@ -44,6 +44,16 @@ pub struct Helix { } impl Helix { + #[cfg(test)] + pub(crate) fn normal() -> Self { + Self { + base: MinimalHelix::normal(), + pending: Pending::None, + count: 0, + select_mode: false, + } + } + fn key_press(code: KeyCode, modifiers: KeyModifiers) -> ReedlineRawEvent { Event::Key(KeyEvent { code, @@ -605,10 +615,14 @@ mod tests { } } + fn normal_hx() -> Helix { + Helix::normal() + } + fn select_hx() -> Helix { Helix { select_mode: true, - ..Default::default() + ..normal_hx() } } @@ -625,7 +639,7 @@ mod tests { #[test] fn v_toggles_select_mode() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); // Normal -> Select let event = hx.parse_event(char_key('v')); assert_eq!(event, ReedlineEvent::Repaint); @@ -654,7 +668,7 @@ mod tests { #[test] fn w_in_normal_produces_word_motion() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('w')); @@ -672,7 +686,7 @@ mod tests { #[test] fn count_not_consumed_by_editing_commands() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); // Press '3' then 'd' — count should be discarded, not affect deletion. hx.parse_event(char_key('3')); assert_eq!(hx.count, 3); @@ -691,7 +705,7 @@ mod tests { #[test] fn count_applies_to_j_k() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('3')); let event = hx.parse_event(char_key('j')); assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 3)); @@ -957,7 +971,7 @@ mod tests { #[test] fn count_prefix_repeats_h_motion_normal() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); // Press '3' then 'h' — Normal mode batches moves + one restart. let event = hx.parse_event(char_key('3')); assert_eq!(event, ReedlineEvent::None); @@ -984,7 +998,7 @@ mod tests { #[test] fn count_prefix_passes_to_word_motion() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('2')); let event = hx.parse_event(char_key('w')); assert_eq!( @@ -999,7 +1013,7 @@ mod tests { #[test] fn count_zero_extends_digit() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('1')); hx.parse_event(char_key('0')); let event = hx.parse_event(char_key('l')); @@ -1011,7 +1025,7 @@ mod tests { #[test] fn invalid_key_after_goto_cancels() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('z')); // invalid goto target assert_eq!(event, ReedlineEvent::None); @@ -1020,7 +1034,7 @@ mod tests { #[test] fn invalid_key_after_find_cancels() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('f')); // Esc is handled by the top-level Normal/Select match before pending // resolution, so it resets everything (mode, pending, count). @@ -1031,7 +1045,7 @@ mod tests { #[test] fn goto_gg_moves_to_start() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('g')); assert_eq!( @@ -1045,7 +1059,7 @@ mod tests { #[test] fn goto_ge_is_unbound() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('g')); let event = hx.parse_event(char_key('e')); // ge is not yet implemented (needs PrevWordEnd motion target). @@ -1054,7 +1068,7 @@ mod tests { #[test] fn f_char_produces_extending_motion() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('f')); let event = hx.parse_event(char_key('x')); assert_eq!( @@ -1073,7 +1087,7 @@ mod tests { #[test] fn d_deletes_with_yank() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('d')); assert_eq!( event, @@ -1087,7 +1101,7 @@ mod tests { #[test] fn alt_d_deletes_without_yank() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::ALT)); assert_eq!( event, @@ -1101,7 +1115,7 @@ mod tests { #[test] fn c_changes_with_yank() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('c')); assert_eq!( event, @@ -1119,7 +1133,7 @@ mod tests { #[test] fn y_yanks_preserving_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('y')); assert_eq!( event, @@ -1132,7 +1146,7 @@ mod tests { #[test] fn p_pastes_after_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('p')); assert_eq!( event, @@ -1148,7 +1162,7 @@ mod tests { #[test] fn big_p_pastes_before_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('P')); assert_eq!( event, @@ -1166,7 +1180,7 @@ mod tests { #[test] fn semicolon_restarts_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key(';')); assert_eq!( event, @@ -1176,7 +1190,7 @@ mod tests { #[test] fn percent_selects_entire_buffer() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('%')); assert_eq!( event, @@ -1191,7 +1205,7 @@ mod tests { #[test] fn x_selects_entire_line() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('x')); assert_eq!( event, @@ -1206,7 +1220,7 @@ mod tests { #[test] fn o_flips_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('o')); assert_eq!( event, @@ -1218,7 +1232,7 @@ mod tests { #[test] fn u_undoes() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('u')); assert_eq!( event, @@ -1228,7 +1242,7 @@ mod tests { #[test] fn big_u_redoes() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('U')); assert_eq!( event, @@ -1240,7 +1254,7 @@ mod tests { #[test] fn r_char_replaces_selection() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('r')); let event = hx.parse_event(char_key('z')); assert_eq!( @@ -1256,7 +1270,7 @@ mod tests { #[test] fn tilde_switches_case() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); let event = hx.parse_event(char_key('~')); assert_eq!( event, @@ -1271,7 +1285,7 @@ mod tests { #[test] fn esc_in_normal_resets_pending_and_count() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('5')); // count hx.parse_event(char_key('g')); // pending goto let event = hx.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)); @@ -1285,7 +1299,7 @@ mod tests { #[test] fn big_f_char_produces_extending_motion() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('F')); let event = hx.parse_event(char_key('a')); assert_eq!( @@ -1302,7 +1316,7 @@ mod tests { #[test] fn big_t_char_produces_extending_motion() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('T')); let event = hx.parse_event(char_key('a')); assert_eq!( @@ -1319,7 +1333,7 @@ mod tests { #[test] fn count_with_f_produces_multiple_events() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); hx.parse_event(char_key('2')); hx.parse_event(char_key('f')); let event = hx.parse_event(char_key('x')); @@ -1365,7 +1379,7 @@ mod tests { #[test] fn count_zero_at_start_is_not_count() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); // '0' at start should not be a count digit (count is 0, so it's not > 0) let event = hx.parse_event(char_key('0')); // Falls through to match code block (no motion bound to '0') @@ -1375,7 +1389,7 @@ mod tests { #[test] fn large_count_on_short_buffer_does_not_panic() { - let mut hx = Helix::default(); + let mut hx = normal_hx(); // Enter count 100 hx.parse_event(char_key('1')); hx.parse_event(char_key('0')); diff --git a/src/engine.rs b/src/engine.rs index 72afb8a9c..ad73a9ab1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2297,7 +2297,7 @@ mod tests { fn with_edit_mode_builder_accepts_custom_helix_mode() { use crate::PromptHelixMode; - let reedline = Reedline::create().with_edit_mode(Box::new(crate::Helix::default())); + let reedline = Reedline::create().with_edit_mode(Box::new(crate::Helix::normal())); assert!(matches!( reedline.prompt_edit_mode(), From 7a6ac8ee89e2b10194198394060e15c4ab4d4f3b Mon Sep 17 00:00:00 2001 From: schlich Date: Thu, 12 Mar 2026 19:16:42 +0000 Subject: [PATCH 15/15] fix: make sure selection & cursor behavior is correct when switching modes --- src/core_editor/editor.rs | 53 +++++++++++++++++++++++++++++++++++++++ src/edit_mode/helix.rs | 6 ++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 9e21a4de4..c0d985431 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -3226,6 +3226,59 @@ mod test { assert_eq!(editor.get_buffer(), "xworld"); } + #[test] + fn i_mode_escape_keeps_cursor_after_inserted_text() { + let mut editor = editor_with("world"); + editor.set_edit_mode(PromptEditMode::Helix(PromptHelixMode::Insert)); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + editor.run_edit_command(&EditCommand::HxMoveToSelectionStart); + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxShiftSelectionToInsertionPoint); + + assert_eq!(editor.get_buffer(), "xyworld"); + assert_eq!(editor.insertion_point(), 2); + + editor.run_edit_command(&EditCommand::HxEnsureSelection); + + assert_eq!(editor.get_buffer(), "xyworld"); + assert_eq!(editor.insertion_point(), 2); + + let sel = *editor.hx_selection().unwrap(); + assert_eq!((sel.anchor, sel.head), (2, 3)); + } + + #[test] + fn a_mode_escape_preserves_entire_selection() { + let mut editor = editor_with("hello"); + editor.set_edit_mode(PromptEditMode::Helix(PromptHelixMode::Insert)); + editor.line_buffer.set_insertion_point(0); + editor.run_edit_command(&EditCommand::HxRestartSelection); + + editor.run_edit_command(&EditCommand::HxMoveToSelectionEnd); + editor.run_edit_command(&EditCommand::InsertChar('x')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + editor.run_edit_command(&EditCommand::InsertChar('y')); + editor.run_edit_command(&EditCommand::HxExtendSelectionToInsertionPoint); + + assert_eq!(editor.get_buffer(), "hxyello"); + assert_eq!(editor.insertion_point(), 3); + + let before_escape = *editor.hx_selection().unwrap(); + assert_eq!((before_escape.anchor, before_escape.head), (0, 3)); + + editor.run_edit_command(&EditCommand::HxEnsureSelection); + + assert_eq!(editor.get_buffer(), "hxyello"); + assert_eq!(editor.insertion_point(), 3); + + let after_escape = *editor.hx_selection().unwrap(); + assert_eq!(after_escape, before_escape); + } + #[test] fn a_mode_extend_tracks_backspace() { // Start: "hello", selection [h] at (0, 1). diff --git a/src/edit_mode/helix.rs b/src/edit_mode/helix.rs index 16d172d8b..a96969af3 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -68,8 +68,7 @@ impl EditMode for Helix { self.selection_adjustment = None; ReedlineEvent::Multiple(vec![ ReedlineEvent::Esc, - ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), - ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Edit(vec![EditCommand::HxEnsureSelection]), ReedlineEvent::Repaint, ]) } @@ -288,8 +287,7 @@ mod tests { helix_mode.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)), ReedlineEvent::Multiple(vec![ ReedlineEvent::Esc, - ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), - ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]), + ReedlineEvent::Edit(vec![EditCommand::HxEnsureSelection]), ReedlineEvent::Repaint, ]) );