diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/selection.rs | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/selection.rs')
| -rw-r--r-- | src/buffer/selection.rs | 344 |
1 files changed, 344 insertions, 0 deletions
diff --git a/src/buffer/selection.rs b/src/buffer/selection.rs new file mode 100644 index 0000000..e30d617 --- /dev/null +++ b/src/buffer/selection.rs @@ -0,0 +1,344 @@ +//! 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<Range>, + 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); + } +} |
