aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/selection.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/selection.rs
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (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.rs344
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);
+ }
+}