diff --git a/src/core_editor/cursor.rs b/src/core_editor/cursor.rs index 67013ffd..ae821d0d 100644 --- a/src/core_editor/cursor.rs +++ b/src/core_editor/cursor.rs @@ -64,6 +64,37 @@ impl CaretGeometry { } } +/// How a mode's `Extend` grows a selection — the selection-model axis, chosen +/// per edit mode by [`PromptEditMode::selection_extent`](crate::PromptEditMode). +/// Orthogonal to [`CaretGeometry`]: geometry is *where the caret rests*, extent +/// is *how far a motion drags the head*. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SelectionExtent { + /// Vi visual: the block cursor sweeps the grapheme it lands *on*, so head + /// covers the motion target: `vw` over `"foo bar"` selects `"foo b"`. + CoverLanding, + /// Helix: the selection is the gap-indexed operator span (`op_end`), so the + /// head stops at the motion boundary: `w` selects `"foo "`, caret on the + /// space. + Span, +} + +/// Keep the anchor's grapheme covered when a block selection reverses direction +/// across it (Helix `Range::put_cursor`): the anchor hops to the far edge of its +/// grapheme so it stays inside the range. A bar (exclusive) selection is half-open +/// `[min, max)`, so its anchor never moves. +fn flip_anchor(buf: &str, anchor: usize, old_head: usize, new_head: usize, block: bool) -> usize { + if !block { + anchor + } else if old_head >= anchor && new_head < anchor { + next_grapheme_boundary(buf, anchor) + } else if old_head < anchor && new_head >= anchor { + prev_grapheme_boundary(buf, anchor) + } else { + anchor + } +} + /// A cursor as a (possibly empty) range over a buffer. /// /// Uses gap indexing — `anchor` and `head` represent positions *between* bytes, @@ -236,23 +267,13 @@ impl Cursor { } let inclusive = geometry.is_inclusive(); - // Flip the anchor onto the far edge of its grapheme when the direction - // changes, so the grapheme the selection started on stays covered (Helix - // `Range::put_cursor`). This is *inclusive* (block) behavior; an exclusive - // (Between / emacs) selection is half-open `[min, max)`, so its anchor - // never moves on reversal. - let anchor: usize = if !inclusive { - self.anchor - } else if self.head >= self.anchor && target < self.anchor { - next_grapheme_boundary(buf, self.anchor) - } else if self.head < self.anchor && target >= self.anchor { - prev_grapheme_boundary(buf, self.anchor) - } else { - self.anchor - }; + // Flip the anchor onto the far edge of its grapheme when direction + // changes, so the grapheme the selection started on stays covered. + let anchor = flip_anchor(buf, self.anchor, self.head, target, inclusive); // Place the head so `caret()` lands back on `target`'s grapheme: forward // *and inclusive* → head on the far edge; otherwise → head *is* `target`. + // This forward widening is the `CoverLanding` selection model. let head = if anchor <= target && inclusive { next_grapheme_boundary(buf, target) } else { @@ -261,6 +282,16 @@ impl Cursor { Self::new(anchor, head) } + + /// Grow a selection under the [`SelectionExtent::Span`] model: place the head + /// at the motion's gap-indexed end (`op_end`, with per-motion inclusivity + /// already baked in by [`resolve_motion`](super::resolve_motion)), with *no* + /// vi-visual widening, while keeping the anchor grapheme covered through a + /// block reversal via the same [`flip_anchor`] as [`Cursor::put_cursor`]. + pub(crate) fn extend_span(self, buf: &str, op_end: usize, geometry: CaretGeometry) -> Self { + let anchor = flip_anchor(buf, self.anchor, self.head, op_end, geometry.is_inclusive()); + Self::new(anchor, op_end) + } } impl Default for Cursor { @@ -629,4 +660,46 @@ mod tests { assert_eq!(c, Cursor::new(5, 0)); assert!(c.contains(3) && c.contains(4)); // both bytes of é covered } + + // --- extend_span (Span selection model) ---------------------------------- + + #[test] + fn extend_span_bar_grows_head_no_flip() { + // The emacs / default path: Bar geometry makes flip_anchor a no-op, so + // the head jumps to op_end and the anchor never moves: [0,3) → [0,4). + let c = Cursor::new(0, 3).extend_span("hello", 4, CaretGeometry::Bar); + assert_eq!(c, Cursor::new(0, 4)); + } + + #[test] + fn extend_span_does_not_widen_like_put_cursor() { + // The property that separates the two models. From the same [0,1) a + // forward Extend to 2 under Block: put_cursor (CoverLanding) widens the + // head onto the landing grapheme's far edge (3); extend_span (Span) stops + // the head exactly at op_end (2) with no widening. + let landing = + Cursor::new(0, 1).put_cursor("hello", 2, Movement::Extend, CaretGeometry::Block); + assert_eq!(landing, Cursor::new(0, 3)); + let span = Cursor::new(0, 1).extend_span("hello", 2, CaretGeometry::Block); + assert_eq!(span, Cursor::new(0, 2)); + } + + #[test] + fn extend_span_block_flip_forward_to_backward_keeps_anchor_grapheme() { + // Helix groundwork (no current mode pairs Span with Block): a Span that + // reverses past the anchor still hops it onto the far edge — the same + // flip_anchor as put_cursor — but the head lands exactly on op_end with + // no widening. [2,4) → op_end 0 → [3,0). + let c = Cursor::new(2, 4).extend_span("hello", 0, CaretGeometry::Block); + assert_eq!(c, Cursor::new(3, 0)); + assert!(c.contains(2)); // start grapheme survives the reversal + } + + #[test] + fn extend_span_block_flip_backward_to_forward_keeps_anchor_grapheme() { + // backward [4,2) Span reverses right to op_end 5 → anchor hops 4→3 → [3,5). + let c = Cursor::new(4, 2).extend_span("hello", 5, CaretGeometry::Block); + assert_eq!(c, Cursor::new(3, 5)); + assert!(c.contains(3)); // the 'l' at 3 (start grapheme) survived + } } diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index d13b6491..244fdb0a 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,4 +1,6 @@ -use super::{edit_stack::EditStack, CaretGeometry, Clipboard, Cursor, LineBuffer, Movement}; +use super::{ + edit_stack::EditStack, CaretGeometry, Clipboard, Cursor, LineBuffer, Movement, SelectionExtent, +}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; use crate::core_editor::graphemes::{next_grapheme_boundary, prev_grapheme_boundary}; @@ -116,10 +118,22 @@ impl Editor { let head = self.resolve_head(*t); self.move_head_to(head, false); } - EditCommand::Extend(t) => { - let head = self.resolve_head(*t); - self.move_head_to(head, true); - } + EditCommand::Extend(t) => match self.caret_extent() { + SelectionExtent::CoverLanding => { + let head = self.resolve_head(*t); + self.move_head_to(head, true); + } + SelectionExtent::Span => { + let geom = self.caret_geometry(); + let origin = self.insertion_point(); + let op_end = resolve_motion(self.get_buffer(), origin, *t, geom).op_end; + let next = + self.line_buffer + .cursor() + .extend_span(self.get_buffer(), op_end, geom); + self.place(next); + } + }, EditCommand::Cut { target, granularity, @@ -555,16 +569,18 @@ impl Editor { } } - /// Move the cursor head to `head` — collapsing the selection unless `select` - /// keeps the anchor — then normalize at the commit boundary (RestPolicy snap - /// and selection bookkeeping). The single sink every motion funnels through, - /// after its target has been resolved via [`resolve_motion`]. - /// The single motion sink: place the caret on the grapheme at `target` via - /// [`Cursor::put_cursor`] (Helix's central op), then normalize at the commit - /// boundary. `select` keeps the anchor (Extend) or collapses (Move); the - /// per-mode selection geometry (inclusive block vs exclusive bar) is the - /// `inclusive` argument, so inclusivity is now carried by the range itself — - /// there is no `selection_inclusive` side-channel to maintain. + /// Place the caret on the grapheme at `target` via [`Cursor::put_cursor`] + /// (Helix's central op), collapsing the selection unless `select` keeps the + /// anchor, then normalize at the commit boundary (RestPolicy snap and + /// selection bookkeeping). The per-mode geometry (inclusive block vs + /// exclusive bar) rides on [`caret_geometry`](Self::caret_geometry), so + /// inclusivity is carried by the range itself — there is no + /// `selection_inclusive` side-channel to maintain. + /// + /// The sink for [`CoverLanding`](SelectionExtent::CoverLanding) placement: + /// every `Move`, and every `Extend` under that extent, funnels here after + /// its target is resolved via [`resolve_motion`]. [`SelectionExtent::Span`] + /// extension goes around it through [`Cursor::extend_span`]. fn move_head_to(&mut self, target: usize, select: bool) { let next = self.line_buffer.cursor().put_cursor( self.line_buffer.get_buffer(), @@ -572,10 +588,28 @@ impl Editor { Movement::select(select), self.caret_geometry(), ); + self.place(next); + } + + /// Install an already-placed [`Cursor`] and normalize it at the commit + /// boundary — the shared tail of every placement strategy ([`put_cursor`]'s + /// `CoverLanding` via [`move_head_to`](Self::move_head_to), [`extend_span`]'s + /// `Span`). The strategy decides *where* the caret goes; `place` is *how* it + /// lands: `set_cursor` then [`commit_cursor`](Self::commit_cursor). + /// + /// [`put_cursor`]: Cursor::put_cursor + /// [`extend_span`]: Cursor::extend_span + fn place(&mut self, next: Cursor) { self.line_buffer.set_cursor(next); self.commit_cursor(); } + /// The active mode's selection model: how `Extend` places the head (vi-visual + /// `CoverLanding` vs bar/helix `Span`). Orthogonal to [`caret_geometry`](Self::caret_geometry). + fn caret_extent(&self) -> SelectionExtent { + self.edit_mode.selection_extent() + } + /// Lower a [`MotionTarget`] onto the cursor (the `Move`/`Extend` path): /// resolve the head per the active policy, then place it — collapsing the /// selection unless `select` keeps the anchor. The shared sink the legacy @@ -3720,6 +3754,44 @@ mod test { assert_eq!(editor.get_selection(), Some((0, 4))); // anchor stays at the origin } + #[test] + fn vi_visual_extend_word_covers_landing() { + // `CoverLanding`: vi visual sweeps the grapheme the motion lands *on*, so + // `Extend(w)` over "foo bar" selects "foo b" (vim's inclusive visual). + let mut editor = editor_with("foo bar baz"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Visual)); + editor.move_to_position(0, false); + editor.run_edit_command(&EditCommand::Extend(word_start_fwd())); + assert_eq!(editor.get_selection(), Some((0, 5))); + assert_eq!(editor.insertion_point(), 4); + } + + #[test] + fn emacs_extend_word_is_exclusive_span() { + // The contrast that proves the axis is real: the *same* `Extend(w)` from + // the *same position in a `Span` (bar) mode stops at the boundary "foo " + // instead of sweeping the landing grapheme. + let mut editor = editor_with("foo bar baz"); + editor.set_edit_mode(PromptEditMode::Emacs); + editor.move_to_position(0, false); + editor.run_edit_command(&EditCommand::Extend(word_start_fwd())); + assert_eq!(editor.get_selection(), Some((0, 4))) + } + + #[test] + fn emacs_extend_word_twice_grows_span() { + // A second `Extend` resolves the next motion from the live head and grows + // the existing Span — not from a collapsed or retreated caret. "foo bar + // baz": 0 → "foo " (0,4) → "foo bar " (0,8), anchor pinned at the origin. + let mut editor = editor_with("foo bar baz"); + editor.set_edit_mode(PromptEditMode::Emacs); + editor.move_to_position(0, false); + editor.run_edit_command(&EditCommand::Extend(word_start_fwd())); + assert_eq!(editor.get_selection(), Some((0, 4))); + editor.run_edit_command(&EditCommand::Extend(word_start_fwd())); + assert_eq!(editor.get_selection(), Some((0, 8))); + } + #[test] fn cut_word_forward_removes_range_and_yanks() { let mut editor = editor_with("foo bar baz"); diff --git a/src/core_editor/mod.rs b/src/core_editor/mod.rs index fbed5b6a..b211dbbf 100644 --- a/src/core_editor/mod.rs +++ b/src/core_editor/mod.rs @@ -12,7 +12,7 @@ mod word; #[cfg(feature = "system_clipboard")] pub(crate) use clip_buffer::get_system_clipboard; pub(crate) use clip_buffer::{get_local_clipboard, Clipboard}; -pub(crate) use cursor::{CaretGeometry, Cursor, Movement}; +pub(crate) use cursor::{CaretGeometry, Cursor, Movement, SelectionExtent}; pub use editor::Editor; pub use line_buffer::LineBuffer; pub(crate) use resolve::{operator_span, resolve_motion}; diff --git a/src/prompt/base.rs b/src/prompt/base.rs index 4a46818e..283c7a22 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -1,5 +1,5 @@ use { - crate::core_editor::RestPolicy, + crate::core_editor::{RestPolicy, SelectionExtent}, crossterm::style::Color, serde::{Deserialize, Serialize}, std::{ @@ -77,6 +77,21 @@ impl PromptEditMode { PromptEditMode::Custom(_) => RestPolicy::Between, } } + + pub(crate) fn selection_extent(&self) -> SelectionExtent { + match self { + // Vi normal/visual sweep the block cursor over the grapheme it + // lands on (vim's inclusive visual: `vw` selects "foo b"). + PromptEditMode::Vi(_) => SelectionExtent::CoverLanding, + // The bar modes never form a block selection, and `op_end` is + // exclusive for the word/line/grapheme motions they emit (a forward + // find stays inclusive, matching its operator span), so the + // gap-indexed `Span` is the natural reading. Helix will use this one! + PromptEditMode::Default | PromptEditMode::Emacs | PromptEditMode::Custom(_) => { + SelectionExtent::Span + } + } + } } /// The vi-specific modes that the prompt can be in