Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 87 additions & 14 deletions src/core_editor/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}
102 changes: 87 additions & 15 deletions src/core_editor/editor.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -555,27 +569,47 @@ 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(),
target,
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
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/core_editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
17 changes: 16 additions & 1 deletion src/prompt/base.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use {
crate::core_editor::RestPolicy,
crate::core_editor::{RestPolicy, SelectionExtent},
crossterm::style::Color,
serde::{Deserialize, Serialize},
std::{
Expand Down Expand Up @@ -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
Expand Down
Loading