//! Selection model — byte-indexed, following Helix's `helix-core/src/selection.rs`. //! //! This is what makes the editor selection-first rather than Vim-like: a motion //! leaves a *range*, and an operator such as `d` acts on that range. There is no //! operator-pending state anywhere in the editor. //! //! Conventions, preserved exactly from Helix: //! //! - A range is **half-open**: inclusive of `from()`, exclusive of `to()`, //! regardless of whether `head` precedes or follows `anchor`. //! - The visible block cursor spans one grapheme *inward* from the head, so a //! forward range `0..1` shows its cursor on byte 0, not byte 1. //! //! Per MJB-LLR-009 a `Selection` holds exactly one range. It is a struct rather //! than a bare `Range` so that multi-cursor support can be added later without //! reworking call sites. use ropey::RopeSlice; use super::grapheme::{next_grapheme_boundary, prev_grapheme_boundary}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { Forward, Backward, } /// MJB-LLR-001: a range over the buffer, both offsets in **bytes**. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Range { /// The side that stays put when extending. pub anchor: usize, /// The side that moves when extending. pub head: usize, } impl Range { pub fn new(anchor: usize, head: usize) -> Self { Self { anchor, head } } /// A zero-width range at `head`. pub fn point(head: usize) -> Self { Self { anchor: head, head } } /// MJB-LLR-011: clamp both offsets into the buffer and snap them to char /// boundaries. Byte indexing permits offsets that char indexing could not /// express, and ropey panics on them — see MJB-DR-002. pub fn clamped(self, text: RopeSlice) -> Self { let len = text.len(); Self { anchor: text.floor_char_boundary(self.anchor.min(len)), head: text.floor_char_boundary(self.head.min(len)), } } /// MJB-LLR-002: lower bound, inclusive. pub fn from(&self) -> usize { self.anchor.min(self.head) } /// MJB-LLR-002: upper bound, exclusive. pub fn to(&self) -> usize { self.anchor.max(self.head) } /// MJB-LLR-003 pub fn is_empty(&self) -> bool { self.anchor == self.head } /// Byte length of the span. pub fn len(&self) -> usize { self.to() - self.from() } /// MJB-LLR-004 pub fn direction(&self) -> Direction { if self.head < self.anchor { Direction::Backward } else { Direction::Forward } } /// MJB-LLR-005: the byte offset the block cursor is drawn at. /// /// For a forward range the head sits *past* the last selected grapheme, so /// the cursor steps back one grapheme to land on it. pub fn cursor(&self, text: RopeSlice) -> usize { if self.head > self.anchor { prev_grapheme_boundary(text, self.head) } else { self.head } } /// MJB-LLR-006, MJB-LLR-007: move the cursor to `byte_idx`. /// /// Without `extend` this collapses to a point. With `extend` the anchor is /// nudged by one grapheme when the range flips direction across it, so the /// anchored grapheme stays selected — this is Helix's `put_cursor`. pub fn put_cursor(self, text: RopeSlice, byte_idx: usize, extend: bool) -> Self { if !extend { return Range::point(byte_idx).clamped(text); } let anchor = if self.head >= self.anchor && byte_idx < self.anchor { next_grapheme_boundary(text, self.anchor) } else if self.head < self.anchor && byte_idx >= self.anchor { prev_grapheme_boundary(text, self.anchor) } else { self.anchor }; if anchor <= byte_idx { Range::new(anchor, next_grapheme_boundary(text, byte_idx)).clamped(text) } else { Range::new(anchor, byte_idx).clamped(text) } } /// The line the cursor lies on. pub fn cursor_line(&self, text: RopeSlice) -> usize { text.byte_to_line_idx(self.cursor(text), super::LINE_TYPE) } /// MJB-LLR-008: inclusive span of line indices the range covers. pub fn line_range(&self, text: RopeSlice) -> (usize, usize) { let lt = super::LINE_TYPE; let start = text.byte_to_line_idx(self.from(), lt); // An exclusive upper bound sitting exactly on a line start belongs to // the previous line, otherwise `x` on a full line would report two. let end_byte = if self.to() > self.from() { self.to() - 1 } else { self.to() }; let end = text.byte_to_line_idx(end_byte.min(text.len()), lt); (start, end) } /// Flip anchor and head, keeping the same span. pub fn flipped(self) -> Self { Range::new(self.head, self.anchor) } } /// MJB-LLR-009: exactly one range, with `primary_index` pinned at zero. /// /// The vector and index exist so the multi-cursor shape is already in place; /// the invariant is asserted, not assumed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Selection { ranges: Vec, primary_index: usize, } impl Default for Selection { fn default() -> Self { Self::point(0) } } impl Selection { pub fn single(range: Range) -> Self { Self { ranges: vec![range], primary_index: 0, } } pub fn point(byte_idx: usize) -> Self { Self::single(Range::point(byte_idx)) } /// MJB-LLR-010 pub fn primary(&self) -> Range { self.ranges[self.primary_index] } pub fn set_primary(&mut self, range: Range) { self.ranges[self.primary_index] = range; } pub fn ranges(&self) -> &[Range] { &self.ranges } /// MJB-LLR-009: the single-range invariant, checked rather than assumed. pub fn invariant_holds(&self) -> bool { self.ranges.len() == 1 && self.primary_index == 0 } /// Clamp every range into `text`. pub fn clamped(mut self, text: RopeSlice) -> Self { for r in &mut self.ranges { *r = r.clamped(text); } self } } #[cfg(test)] mod tests { use ropey::Rope; use super::*; /// MJB-LLR-001: offsets are bytes, not characters. A range over a /// multi-byte character must report its byte extent. #[test] fn mjb_llr_001_offsets_are_byte_indices() { let r = Rope::from_str("文字"); let s = r.slice(..); assert_eq!(s.len(), 6, "two 3-byte characters"); let whole = Range::new(0, 6).clamped(s); assert_eq!(whole.len(), 6, "length is in bytes, not characters"); // A single character spans three byte offsets. let first = Range::new(0, 3).clamped(s); assert_eq!(first.len(), 3); } #[test] fn mjb_llr_002_from_and_to_ignore_direction() { assert_eq!(Range::new(2, 5).from(), 2); assert_eq!(Range::new(2, 5).to(), 5); assert_eq!(Range::new(5, 2).from(), 2, "backward range still orders"); assert_eq!(Range::new(5, 2).to(), 5); } #[test] fn mjb_llr_003_is_empty() { assert!(Range::point(3).is_empty()); assert!(!Range::new(3, 4).is_empty()); } #[test] fn mjb_llr_004_direction() { assert_eq!(Range::new(1, 5).direction(), Direction::Forward); assert_eq!(Range::new(5, 1).direction(), Direction::Backward); assert_eq!( Range::point(2).direction(), Direction::Forward, "an empty range is forward by convention" ); } #[test] fn mjb_llr_005_cursor_steps_back_on_forward_range() { let r = Rope::from_str("abcdef"); let s = r.slice(..); // Forward 0..1 selects byte 0, so the cursor is drawn on byte 0. assert_eq!(Range::new(0, 1).cursor(s), 0); assert_eq!(Range::new(0, 3).cursor(s), 2); // A backward range's head already sits on the cursor. assert_eq!(Range::new(3, 0).cursor(s), 0); assert_eq!(Range::point(4).cursor(s), 4); } #[test] fn mjb_llr_005_cursor_respects_grapheme_clusters() { let r = Rope::from_str("文字"); let s = r.slice(..); // Head past the first wide char: cursor lands on its start, not mid-char. assert_eq!(Range::new(0, 3).cursor(s), 0); } #[test] fn mjb_llr_006_put_cursor_without_extend_collapses() { let r = Rope::from_str("abcdef"); let s = r.slice(..); let got = Range::new(0, 4).put_cursor(s, 2, false); assert_eq!(got, Range::point(2)); } #[test] fn mjb_llr_007_put_cursor_with_extend_keeps_anchor() { let r = Rope::from_str("abcdef"); let s = r.slice(..); let got = Range::new(1, 2).put_cursor(s, 4, true); assert_eq!(got.anchor, 1, "anchor stays put when extending forward"); assert_eq!(got.head, 5, "head lands one grapheme past the target"); } #[test] fn mjb_llr_007_put_cursor_extend_flips_direction() { let r = Rope::from_str("abcdef"); let s = r.slice(..); // Forward range extended to before its anchor must flip and nudge the // anchor forward one grapheme so the anchored byte stays selected. let got = Range::new(2, 4).put_cursor(s, 0, true); assert_eq!(got.direction(), Direction::Backward); assert_eq!(got.anchor, 3); assert_eq!(got.head, 0); } #[test] fn mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary() { let r = Rope::from_str("文"); let s = r.slice(..); assert_eq!(Range::new(0, 99).clamped(s).head, 3, "clamped to length"); assert_eq!( Range::new(0, 1).clamped(s).head, 0, "an offset inside a multi-byte char snaps back to its start" ); } #[test] fn mjb_llr_008_line_range() { let r = Rope::from_str("aa\nbb\ncc\n"); let s = r.slice(..); assert_eq!(Range::point(0).line_range(s), (0, 0)); // Exactly one full line, terminator included, is still one line. assert_eq!(Range::new(0, 3).line_range(s), (0, 0)); assert_eq!(Range::new(0, 6).line_range(s), (0, 1)); } #[test] fn mjb_llr_009_selection_invariant() { let sel = Selection::point(0); assert!(sel.invariant_holds()); assert_eq!(sel.ranges().len(), 1); } #[test] fn mjb_llr_010_primary_round_trips() { let mut sel = Selection::point(0); sel.set_primary(Range::new(1, 4)); assert_eq!(sel.primary(), Range::new(1, 4)); } #[test] fn flipped_preserves_span() { let r = Range::new(2, 7).flipped(); assert_eq!((r.anchor, r.head), (7, 2)); assert_eq!(r.from(), 2); assert_eq!(r.to(), 7); } }