diff --git a/.typos.toml b/.typos.toml index 805fe638..76d05433 100644 --- a/.typos.toml +++ b/.typos.toml @@ -21,3 +21,4 @@ l3ine = "l3ine" 4should = "4should" wr5ap = "wr5ap" ine = "ine" +worl = "worl" diff --git a/examples/demo.rs b/examples/demo.rs index 83c6883c..4d1373cf 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -73,6 +73,15 @@ fn main() -> reedline::Result<()> { ]; let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); + #[cfg(feature = "helix")] + let cursor_config = CursorConfig { + vi_insert: Some(SetCursorStyle::BlinkingBar), + vi_normal: Some(SetCursorStyle::SteadyBlock), + emacs: None, + ..CursorConfig::default() + }; + + #[cfg(not(feature = "helix"))] let cursor_config = CursorConfig { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), diff --git a/examples/helix.rs b/examples/helix.rs index 6c6bd393..2aafae41 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 custom mode indicator as "(helix)". +// 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; @@ -11,7 +12,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/core_editor/editor.rs b/src/core_editor/editor.rs index 67fafc63..c1ee7ebc 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,11 +1,18 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; +#[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}; use crate::{core_editor::get_local_clipboard, EditCommand}; use std::cmp::{max, min}; use std::ops::{DerefMut, Range}; +#[cfg(feature = "helix")] +use unicode_segmentation::UnicodeSegmentation; /// Stateful editor executing changes to the underlying [`LineBuffer`] /// @@ -21,6 +28,8 @@ pub struct Editor { selection_anchor: Option, selection_mode: Option, edit_mode: PromptEditMode, + #[cfg(feature = "helix")] + hx_selection: Option, } impl Default for Editor { @@ -35,6 +44,73 @@ impl Default for Editor { selection_anchor: None, selection_mode: None, edit_mode: PromptEditMode::Default, + #[cfg(feature = "helix")] + 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 = "helix")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HxRange { + pub(crate) anchor: usize, + pub(crate) head: usize, +} + +#[cfg(feature = "helix")] +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 } } } @@ -198,11 +274,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 = "helix")] + EditCommand::HxRestartSelection => self.hx_restart_selection(), + #[cfg(feature = "helix")] + EditCommand::HxClearSelection => self.reset_hx_state(), + #[cfg(feature = "helix")] + EditCommand::HxEnsureSelection => self.hx_ensure_selection(), + #[cfg(feature = "helix")] + EditCommand::HxSyncCursor => self.hx_sync_cursor(), + #[cfg(feature = "helix")] + EditCommand::HxSyncCursorWithRestart => self.hx_sync_cursor_with_restart(), + #[cfg(feature = "helix")] + EditCommand::HxWordMotion { + target, + movement, + count, + } => self.hx_word_motion(*target, *movement, *count), + #[cfg(feature = "helix")] + EditCommand::HxFlipSelection => self.hx_flip_selection(), + #[cfg(feature = "helix")] + EditCommand::HxMoveToSelectionStart => self.hx_move_to_selection_start(), + #[cfg(feature = "helix")] + EditCommand::HxMoveToSelectionEnd => self.hx_move_to_selection_end(), + #[cfg(feature = "helix")] + EditCommand::HxSwitchCaseSelection => self.hx_switch_case_selection(), + #[cfg(feature = "helix")] + EditCommand::HxReplaceSelectionWithChar(c) => self.hx_replace_selection_with_char(*c), + #[cfg(feature = "helix")] + EditCommand::HxDeleteSelection => self.hx_delete_selection(), + #[cfg(feature = "helix")] + EditCommand::HxExtendSelectionToInsertionPoint => { + self.hx_extend_selection_to_insertion_point() + } + #[cfg(feature = "helix")] + 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, @@ -232,6 +349,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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "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 = "helix")] + #[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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "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()); + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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 = "helix")] + 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) { @@ -249,6 +703,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 = "helix")] + { + matches!(self.edit_mode, PromptEditMode::Helix(_)) + } + #[cfg(not(feature = "helix"))] + { + false + } + } fn move_to_position(&mut self, position: usize, select: bool) { self.update_selection_anchor(select); self.line_buffer.set_insertion_point(position) @@ -548,7 +1014,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); } @@ -563,7 +1030,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); } @@ -647,6 +1115,8 @@ impl Editor { self.system_clipboard.set(cut_slice, ClipboardMode::Normal); self.cut_range(start..end); self.clear_selection(); + #[cfg(feature = "helix")] + self.reset_hx_state(); } } @@ -654,6 +1124,8 @@ impl Editor { if let Some((start, end)) = self.get_selection() { self.cut_range(start..end); self.clear_selection(); + #[cfg(feature = "helix")] + self.reset_hx_state(); } } @@ -674,7 +1146,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 = "helix")] + 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 @@ -711,9 +1200,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 = "helix")] + self.reset_hx_state(); } } @@ -2151,4 +2651,711 @@ mod test { assert_eq!(bracket_result, expected_bracket); assert_eq!(quote_result, expected_quote); } + + #[cfg(feature = "helix")] + 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); + } + + // ── SelectionAdjustment::Shifting (`i`) 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 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). + 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"); + } + + // ── SelectionAdjustment::Anchored (`a`) 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 416e13fe..2177b23b 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 b30009bb..ca6267f8 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 = "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 67a1765a..294c645d 100644 --- a/src/edit_mode/cursors.rs +++ b/src/edit_mode/cursors.rs @@ -1,7 +1,19 @@ +#[cfg(feature = "helix")] +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 = "helix")] + pub custom: HashMap, +} + +#[cfg(feature = "helix")] +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 = "helix")] +pub const HX_CURSOR_NORMAL: &str = "HX_NORMAL"; +/// Cursor config key for Helix Insert mode. +#[cfg(feature = "helix")] +pub const HX_CURSOR_INSERT: &str = "HX_INSERT"; +/// Cursor config key for Helix Select mode. +#[cfg(feature = "helix")] +pub const HX_CURSOR_SELECT: &str = "HX_SELECT"; + +/// Methods available when the `hx` feature is enabled. +#[cfg(feature = "helix")] +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 = "helix")] + #[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 = "helix")] + #[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/helix.rs b/src/edit_mode/helix.rs index ec03dfc8..a96969af 100644 --- a/src/edit_mode/helix.rs +++ b/src/edit_mode/helix.rs @@ -1,64 +1,416 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use crate::{ - enums::{EventStatus, ReedlineEvent, ReedlineRawEvent}, - PromptEditMode, PromptViMode, + edit_mode::EditMode, + enums::{ReedlineEvent, ReedlineRawEvent}, + EditCommand, PromptEditMode, PromptHelixMode, }; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; -use super::EditMode; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum HelixMode { + #[default] + Insert, + Normal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectionAdjustment { + Shifting, + Anchored, +} -/// A minimal custom edit mode example for Helix-style integrations. +/// Minimal Helix-inspired edit mode supporting Normal and Insert states. #[derive(Default)] -pub struct Helix; +pub struct Helix { + mode: HelixMode, + selection_adjustment: Option, +} + +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; + events.push(ReedlineEvent::Repaint); + ReedlineEvent::Multiple(events) + } + + pub(crate) fn enter_plain_insert(&mut self) { + self.mode = HelixMode::Insert; + self.selection_adjustment = None; + } +} 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, + let Event::Key(KeyEvent { + code, modifiers, .. + }) = event.into() + else { + return ReedlineEvent::None; + }; + + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('c') { + return ReedlineEvent::CtrlC; } - } - fn edit_mode(&self) -> PromptEditMode { - PromptEditMode::Vi(PromptViMode::Normal) + match self.mode { + HelixMode::Insert => match (code, modifiers) { + (KeyCode::Esc, _) => { + self.mode = HelixMode::Normal; + self.selection_adjustment = None; + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::HxEnsureSelection]), + ReedlineEvent::Repaint, + ]) + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + ReedlineEvent::Edit(vec![EditCommand::Delete]) + } + (KeyCode::Char(c), _) => match self.selection_adjustment { + Some(SelectionAdjustment::Shifting) => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + Some(SelectionAdjustment::Anchored) => ReedlineEvent::Edit(vec![ + EditCommand::InsertChar(c), + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + None => ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)]), + }, + (KeyCode::Enter, _) => ReedlineEvent::Enter, + (KeyCode::Backspace, _) => match self.selection_adjustment { + Some(SelectionAdjustment::Shifting) => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxShiftSelectionToInsertionPoint, + ]), + Some(SelectionAdjustment::Anchored) => ReedlineEvent::Edit(vec![ + EditCommand::Backspace, + EditCommand::HxExtendSelectionToInsertionPoint, + ]), + None => ReedlineEvent::Edit(vec![EditCommand::Backspace]), + }, + (KeyCode::Delete, _) => ReedlineEvent::Edit(vec![EditCommand::Delete]), + (KeyCode::Left, _) => { + self.selection_adjustment = None; + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Right, _) => { + self.selection_adjustment = None; + ReedlineEvent::Edit(vec![ + EditCommand::MoveRight { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::Home, _) => { + self.selection_adjustment = None; + ReedlineEvent::Edit(vec![ + EditCommand::MoveToLineStart { select: false }, + EditCommand::HxClearSelection, + ]) + } + (KeyCode::End, _) => { + self.selection_adjustment = None; + 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.selection_adjustment = Some(SelectionAdjustment::Shifting); + self.enter_insert(vec![ReedlineEvent::Edit(vec![ + EditCommand::HxEnsureSelection, + EditCommand::HxMoveToSelectionStart, + ])]) + } + KeyCode::Char('a') => { + self.selection_adjustment = Some(SelectionAdjustment::Anchored); + 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 handle_mode_specific_event(&mut self, _event: ReedlineEvent) -> EventStatus { - EventStatus::Inapplicable + 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::PromptViMode; + 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() { - let helix_mode = Helix; + 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::Vi(PromptViMode::Normal) + PromptEditMode::Helix(PromptHelixMode::Insert) )); } #[test] fn helix_edit_mode_parses_ctrl_c_event() { - let mut helix_mode = Helix; - let ctrl_c_raw_event = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - ))); + let mut helix_mode = Helix::normal(); 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::normal(); + + 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.selection_adjustment, + Some(SelectionAdjustment::Shifting) + ); + } + + #[test] + fn helix_edit_mode_enters_append_with_a() { + let mut helix_mode = Helix::normal(); + + 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.selection_adjustment, + Some(SelectionAdjustment::Anchored) + ); + } + + #[test] + fn helix_edit_mode_exits_insert_with_escape() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + selection_adjustment: Some(SelectionAdjustment::Anchored), + }; + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Esc, KeyModifiers::NONE)), + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Esc, + ReedlineEvent::Edit(vec![EditCommand::HxEnsureSelection]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix_mode.mode, HelixMode::Normal); + 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, + selection_adjustment: None, + }; + + 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::normal(); + + assert_eq!( + helix_mode.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), + ReedlineEvent::CtrlD + ); + } + + #[test] + fn helix_edit_mode_insert_char_uses_shifting_adjustment() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + selection_adjustment: Some(SelectionAdjustment::Shifting), + }; + + assert_eq!( + helix_mode.parse_event(char_key('x')), + ReedlineEvent::Edit(vec![ + EditCommand::InsertChar('x'), + EditCommand::HxShiftSelectionToInsertionPoint, + ]) + ); + } + + #[test] + fn helix_edit_mode_insert_char_uses_anchored_adjustment() { + let mut helix_mode = Helix { + mode: HelixMode::Insert, + selection_adjustment: Some(SelectionAdjustment::Anchored), + }; + + 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::normal(); + + 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::normal(); + + 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::normal(); + + 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, + selection_adjustment: Some(SelectionAdjustment::Shifting), + }; + + 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.selection_adjustment, None); + } } diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs new file mode 100644 index 00000000..9bf1216e --- /dev/null +++ b/src/edit_mode/hx/mod.rs @@ -0,0 +1,1464 @@ +#[cfg(test)] +pub(crate) mod word; + +use super::helix::Helix as MinimalHelix; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + +use crate::{ + edit_mode::EditMode, + enums::{Movement, ReedlineEvent, ReedlineRawEvent, WordMotionTarget}, + EditCommand, PromptEditMode, PromptHelixMode, +}; + +/// 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, +} + +/// Helix-inspired edit mode for reedline. +/// +/// 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 { + base: MinimalHelix, + pending: Pending, + /// Accumulated numeric prefix (0 = none entered). + count: usize, + select_mode: bool, +} + +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, + 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. + /// + /// 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.select_mode { + 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.select_mode { + return Self::hx_extend(cmds); + } + let mut v = cmds; + v.push(EditCommand::HxRestartSelection); + ReedlineEvent::Edit(v) + } + + /// 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.select_mode { + 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, + ) -> Option { + // ── Resolve pending multi-key sequences ───────────────────────── + match self.pending { + 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 Some(self.parse_find_char(p, c)); + } + self.pending = Pending::None; + return Some(ReedlineEvent::None); + } + Pending::Replace => { + if let KeyCode::Char(c) = code { + return Some(self.parse_replace_char(c)); + } + self.pending = Pending::None; + return Some(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 Some(ReedlineEvent::None); + } + KeyCode::Char('0') if self.count > 0 => { + self.count = self.count.saturating_mul(10); + return Some(ReedlineEvent::None); + } + _ => {} + } + + match code { + // ── Pending keys: don't consume count, just set pending state ── + KeyCode::Char('g') => { + self.pending = Pending::Goto; + return Some(ReedlineEvent::None); + } + KeyCode::Char('f') => { + self.pending = Pending::FindForward; + return Some(ReedlineEvent::None); + } + KeyCode::Char('t') => { + self.pending = Pending::TilForward; + return Some(ReedlineEvent::None); + } + KeyCode::Char('F') => { + self.pending = Pending::FindBackward; + return Some(ReedlineEvent::None); + } + KeyCode::Char('T') => { + self.pending = Pending::TilBackward; + return Some(ReedlineEvent::None); + } + KeyCode::Char('r') => { + self.pending = Pending::Replace; + return Some(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 Some(event); + } + + // Everything below ignores count — reset so it doesn't leak. + self.count = 0; + + match code { + // ── Mode switches ───────────────────────────────────────── + 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('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 + } + } + } + } + + /// 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') => { + if !self.select_mode && self.count == 0 { + return None; + } + + let count = self.take_count(); + if self.select_mode { + 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') => { + if !self.select_mode && self.count == 0 { + return None; + } + + 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; + }; + + if self.in_insert_mode() { + return self.delegate_to_base(code, modifiers); + } + + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char('c') { + return ReedlineEvent::CtrlC; + } + + 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 { + if self.select_mode { + PromptEditMode::Helix(PromptHelixMode::Select) + } else { + self.base.edit_mode() + } + } +} + +#[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), + } + } + + fn normal_hx() -> Helix { + Helix::normal() + } + + fn select_hx() -> Helix { + Helix { + select_mode: true, + ..normal_hx() + } + } + + // ── parse_event unit tests ────────────────────────────────────────── + + #[test] + fn ctrl_d_eof_in_select_mode() { + let mut hx = select_hx(); + + let event = hx.parse_event(key_press(KeyCode::Char('d'), KeyModifiers::CONTROL)); + + assert_eq!(event, ReedlineEvent::CtrlD); + } + + #[test] + fn v_toggles_select_mode() { + let mut hx = normal_hx(); + // Normal -> Select + let event = hx.parse_event(char_key('v')); + assert_eq!(event, ReedlineEvent::Repaint); + assert!(hx.select_mode); + + // Select -> Normal (with selection restart) + let event = hx.parse_event(char_key('v')); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert!(!hx.select_mode); + } + + #[test] + fn h_in_select_extends_without_restart() { + let mut hx = select_hx(); + + 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 = normal_hx(); + + 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 count_not_consumed_by_editing_commands() { + 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); + 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 = 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)); + } + + #[test] + fn edit_mode_reports_select_prompt() { + let hx = select_hx(); + + 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 = 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); + 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 = 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')); + assert!(matches!(event, ReedlineEvent::Multiple(ref v) if v.len() == 3)); + } + + #[test] + fn count_prefix_passes_to_word_motion() { + let mut hx = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = 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). + 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 = normal_hx(); + 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 = 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). + assert_eq!(event, ReedlineEvent::None); + } + + #[test] + fn f_char_produces_extending_motion() { + let mut hx = normal_hx(); + 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, + ]) + ); + } + + // ── Edit command tests ──────────────────────────────────────────── + + #[test] + fn d_deletes_with_yank() { + let mut hx = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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!(matches!(edit_mode_hx(&hx), PromptHelixMode::Insert)); + } + + #[test] + fn y_yanks_preserving_selection() { + let mut hx = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + let event = hx.parse_event(char_key(';')); + assert_eq!( + event, + ReedlineEvent::Edit(vec![EditCommand::HxRestartSelection]) + ); + } + + #[test] + fn percent_selects_entire_buffer() { + let mut hx = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = 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)); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert!(!hx.select_mode); + assert_eq!(hx.pending, Pending::None); + assert_eq!(hx.count, 0); + } + + // ── F/T backward find/til tests ───────────────────────────────────── + + #[test] + fn big_f_char_produces_extending_motion() { + let mut hx = normal_hx(); + 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 = normal_hx(); + 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 = normal_hx(); + 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 = 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') + assert_eq!(event, ReedlineEvent::None); + assert_eq!(hx.count, 0); + } + + #[test] + fn large_count_on_short_buffer_does_not_panic() { + let mut hx = normal_hx(); + // 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["); + } + + // ── 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 00000000..dd98998a --- /dev/null +++ b/src/edit_mode/hx/word.rs @@ -0,0 +1,800 @@ +//! 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. +//! +//! 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 +//! 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::*; + + 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. + // 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: &[MotionTestCase<'_>], + ) { + 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 0b6f2f6f..4910da5c 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -3,13 +3,17 @@ mod cursors; mod emacs; #[cfg(feature = "helix")] mod helix; +#[cfg(feature = "helix")] +pub(crate) mod hx; mod keybindings; mod vi; pub use base::EditMode; pub use cursors::CursorConfig; +#[cfg(feature = "helix")] +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}; diff --git a/src/engine.rs b/src/engine.rs index 0364acf4..53dcc82c 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1507,6 +1507,8 @@ impl Reedline { self.update_buffer_from_history(); self.editor.move_to_start(false); self.editor.move_to_line_end(false); + #[cfg(feature = "helix")] + self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation); } @@ -1541,6 +1543,8 @@ impl Reedline { } self.update_buffer_from_history(); self.editor.move_to_end(false); + #[cfg(feature = "helix")] + self.editor.hx_restart_selection(); self.editor .update_undo_state(UndoBehavior::HistoryNavigation) } @@ -1628,6 +1632,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 = "helix")] + 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 @@ -2318,13 +2328,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::normal())); assert!(matches!( reedline.prompt_edit_mode(), - PromptEditMode::Vi(PromptViMode::Normal) + PromptEditMode::Helix(PromptHelixMode::Normal) )); } diff --git a/src/enums.rs b/src/enums.rs index 061fb75c..d75f19a9 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -485,6 +485,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 = "helix")] + HxRestartSelection, + + /// Clear hx selection entirely (e.g. when entering Insert mode) + #[cfg(feature = "helix")] + HxClearSelection, + + /// Ensure an hx selection exists; no-op if one is already set + #[cfg(feature = "helix")] + HxEnsureSelection, + + /// Adjust insertion point to block-cursor display position after a motion + #[cfg(feature = "helix")] + 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 = "helix")] + HxSyncCursorWithRestart, + + /// Helix word/WORD motion (w/b/e/W/B/E) + #[cfg(feature = "helix")] + 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 = "helix")] + HxFlipSelection, + + /// Move cursor to the start of the Helix selection (min of anchor, head) + #[cfg(feature = "helix")] + HxMoveToSelectionStart, + + /// Move cursor to the end of the Helix selection (max of anchor, head) + #[cfg(feature = "helix")] + HxMoveToSelectionEnd, + + /// Toggle case of entire Helix selection + #[cfg(feature = "helix")] + HxSwitchCaseSelection, + + /// Replace every grapheme in the Helix selection with the given char + #[cfg(feature = "helix")] + HxReplaceSelectionWithChar(char), + + /// Delete the Helix selection range without saving to cut buffer + #[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 = "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 = "helix")] + HxShiftSelectionToInsertionPoint, +} + +/// Whether a motion resets the selection anchor (Move) or keeps it (Extend). +#[cfg(feature = "helix")] +#[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 = "helix")] +#[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 { @@ -618,6 +719,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 = "helix")] + EditCommand::HxRestartSelection => write!(f, "HxRestartSelection"), + #[cfg(feature = "helix")] + EditCommand::HxClearSelection => write!(f, "HxClearSelection"), + #[cfg(feature = "helix")] + EditCommand::HxEnsureSelection => write!(f, "HxEnsureSelection"), + #[cfg(feature = "helix")] + EditCommand::HxSyncCursor => write!(f, "HxSyncCursor"), + #[cfg(feature = "helix")] + EditCommand::HxSyncCursorWithRestart => write!(f, "HxSyncCursorWithRestart"), + #[cfg(feature = "helix")] + EditCommand::HxWordMotion { target, .. } => { + write!(f, "HxWordMotion({:?})", target) + } + #[cfg(feature = "helix")] + EditCommand::HxFlipSelection => write!(f, "HxFlipSelection"), + #[cfg(feature = "helix")] + EditCommand::HxMoveToSelectionStart => write!(f, "HxMoveToSelectionStart"), + #[cfg(feature = "helix")] + EditCommand::HxMoveToSelectionEnd => write!(f, "HxMoveToSelectionEnd"), + #[cfg(feature = "helix")] + EditCommand::HxSwitchCaseSelection => write!(f, "HxSwitchCaseSelection"), + #[cfg(feature = "helix")] + EditCommand::HxReplaceSelectionWithChar(c) => { + write!(f, "HxReplaceSelectionWithChar({c})") + } + #[cfg(feature = "helix")] + EditCommand::HxDeleteSelection => write!(f, "HxDeleteSelection"), + #[cfg(feature = "helix")] + EditCommand::HxExtendSelectionToInsertionPoint => { + write!(f, "HxExtendSelectionToInsertionPoint") + } + #[cfg(feature = "helix")] + EditCommand::HxShiftSelectionToInsertionPoint => { + write!(f, "HxShiftSelectionToInsertionPoint") + } } } } @@ -732,6 +869,25 @@ impl EditCommand { | EditCommand::CopyInsidePair { .. } | EditCommand::CopyAroundPair { .. } | EditCommand::CopyTextObject { .. } => EditType::NoOp, + + #[cfg(feature = "helix")] + EditCommand::HxRestartSelection + | EditCommand::HxClearSelection + | EditCommand::HxEnsureSelection + | EditCommand::HxSyncCursor + | EditCommand::HxSyncCursorWithRestart + | EditCommand::HxFlipSelection + | EditCommand::HxMoveToSelectionStart + | EditCommand::HxMoveToSelectionEnd => EditType::NoOp, + #[cfg(feature = "helix")] + EditCommand::HxWordMotion { .. } => EditType::MoveCursor { select: false }, + #[cfg(feature = "helix")] + EditCommand::HxSwitchCaseSelection + | EditCommand::HxReplaceSelectionWithChar(_) + | EditCommand::HxDeleteSelection => EditType::EditText, + #[cfg(feature = "helix")] + EditCommand::HxExtendSelectionToInsertionPoint + | EditCommand::HxShiftSelectionToInsertionPoint => EditType::NoOp, } } } diff --git a/src/lib.rs b/src/lib.rs index b6da798b..a7e8ba53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -243,6 +243,8 @@ pub use enums::{ EditCommand, EditCommandDiscriminants, MouseButton, ReedlineEvent, ReedlineEventDiscriminants, ReedlineRawEvent, Signal, TextObject, TextObjectScope, TextObjectType, UndoBehavior, }; +#[cfg(feature = "helix")] +pub use enums::{Movement, WordMotionTarget}; mod painting; pub use painting::{Painter, StyledText}; @@ -263,18 +265,20 @@ pub use history::{ }; mod prompt; +#[cfg(feature = "helix")] +pub use prompt::PromptHelixMode; pub use prompt::{ DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, }; mod edit_mode; -#[cfg(feature = "helix")] -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 = "helix")] +pub use edit_mode::{Helix, 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 1cf06b29..0a44251b 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 = "helix")] + 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 = "helix")] + 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 f78d696e..3bc41dcb 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 = "helix")] + 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 = "helix")] +#[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 = "helix")] + 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 24625a2c..744cba97 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "helix")] +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 = "helix")] +pub static DEFAULT_HX_NORMAL_PROMPT_INDICATOR: &str = "〉"; +/// The default prompt indicator for helix insert mode +#[cfg(feature = "helix")] +pub static DEFAULT_HX_INSERT_PROMPT_INDICATOR: &str = ": "; +/// The default prompt indicator for helix select mode +#[cfg(feature = "helix")] +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 = "helix")] + 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 = "helix")] + #[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 = "helix")] + #[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 83d2e3b5..36452c87 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -1,6 +1,8 @@ mod base; mod default; +#[cfg(feature = "helix")] +pub use base::PromptHelixMode; pub use base::{ Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, };