aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/grapheme.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/grapheme.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/grapheme.rs')
-rw-r--r--src/buffer/grapheme.rs325
1 files changed, 325 insertions, 0 deletions
diff --git a/src/buffer/grapheme.rs b/src/buffer/grapheme.rs
new file mode 100644
index 0000000..8670853
--- /dev/null
+++ b/src/buffer/grapheme.rs
@@ -0,0 +1,325 @@
+//! Grapheme cluster boundaries and display width over a rope.
+//!
+//! Under byte indexing an offset can land inside a character or inside a
+//! grapheme cluster, so every cursor position the user can observe is snapped
+//! to a grapheme boundary here. See MJB-DR-002.
+//!
+//! `unicode_segmentation::GraphemeCursor` works over `&str` fragments and asks
+//! for more context when a cluster straddles a fragment edge; ropey's
+//! `chunk(byte_idx) -> (&str, chunk_start)` supplies exactly that, so clusters
+//! spanning chunk boundaries resolve correctly (MJB-LLR-022).
+
+use std::borrow::Cow;
+
+use ropey::RopeSlice;
+use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
+use unicode_width::UnicodeWidthStr;
+
+/// Columns a tab advances to. Fixed rather than configurable; a configurable
+/// tab stop would be a new requirement, not a derived one.
+pub const TAB_WIDTH: usize = 4;
+
+/// MJB-LLR-020: byte offset of the grapheme boundary preceding `byte_idx`,
+/// or 0 when there is none.
+pub fn prev_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx == 0 {
+ return 0;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.prev_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return 0,
+ Err(GraphemeIncomplete::PrevChunk) => {
+ // Step back one chunk and retry.
+ let (c, s) = slice.chunk(chunk_start.saturating_sub(1));
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ // The remaining variants cannot arise from prev_boundary with a
+ // cursor built over the whole slice; treat defensively as "no
+ // boundary found" rather than panicking (MJB-HLR-018).
+ Err(_) => return 0,
+ }
+ }
+}
+
+/// MJB-LLR-021: byte offset of the grapheme boundary following `byte_idx`,
+/// or `slice.len()` when there is none.
+pub fn next_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize {
+ let len = slice.len();
+ let byte_idx = slice.floor_char_boundary(byte_idx.min(len));
+ if byte_idx >= len {
+ return len;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (mut chunk, mut chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.next_boundary(chunk, chunk_start) {
+ Ok(Some(n)) => return n,
+ Ok(None) => return len,
+ Err(GraphemeIncomplete::NextChunk) => {
+ let next_start = chunk_start + chunk.len();
+ if next_start >= len {
+ return len;
+ }
+ let (c, s) = slice.chunk(next_start);
+ chunk = c;
+ chunk_start = s;
+ }
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return len,
+ }
+ }
+}
+
+/// MJB-LLR-023: whether `byte_idx` lies on a grapheme cluster boundary.
+pub fn is_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> bool {
+ let len = slice.len();
+ if byte_idx > len || !slice.is_char_boundary(byte_idx) {
+ return false;
+ }
+ if byte_idx == 0 || byte_idx == len {
+ return true;
+ }
+
+ let mut cursor = GraphemeCursor::new(byte_idx, len, true);
+ let (chunk, chunk_start) = slice.chunk(byte_idx);
+
+ loop {
+ match cursor.is_boundary(chunk, chunk_start) {
+ Ok(b) => return b,
+ Err(GraphemeIncomplete::PreContext(n)) => {
+ let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1));
+ cursor.provide_context(ctx, ctx_start);
+ }
+ Err(_) => return false,
+ }
+ }
+}
+
+/// The text in `byte_range` without allocating when it lies in one rope chunk.
+///
+/// Rendering asks for every grapheme on every visible line each frame, so the
+/// obvious `slice.chunks().collect::<String>()` would allocate once per cell
+/// per frame. A grapheme spans a chunk boundary only rarely, and only then is
+/// a copy made.
+pub fn grapheme_str(slice: RopeSlice<'_>, byte_range: std::ops::Range<usize>) -> Cow<'_, str> {
+ let sub = slice.slice(byte_range);
+ match sub.as_str() {
+ Some(s) => Cow::Borrowed(s),
+ None => Cow::Owned(sub.chunks().collect()),
+ }
+}
+
+/// MJB-LLR-024: terminal display width of one grapheme cluster.
+///
+/// A tab is width-dependent on where it starts, so callers pass the column it
+/// begins at. Control characters render as nothing and count zero.
+pub fn grapheme_width(grapheme: &str, at_column: usize) -> usize {
+ if grapheme == "\t" {
+ return TAB_WIDTH - (at_column % TAB_WIDTH);
+ }
+ if grapheme.chars().all(|c| c.is_control()) {
+ return 0;
+ }
+ UnicodeWidthStr::width(grapheme)
+}
+
+/// MJB-LLR-025: display column of `byte_idx` within `line`, accumulating
+/// grapheme widths rather than counting bytes.
+pub fn display_column(line: RopeSlice, byte_idx: usize) -> usize {
+ let limit = line.floor_char_boundary(byte_idx.min(line.len()));
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < limit {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next.min(limit));
+ column += grapheme_width(&g, column);
+ pos = next;
+ }
+ column
+}
+
+/// Inverse of [`display_column`]: the byte offset within `line` whose display
+/// column is nearest to but not beyond `target_column`. Used to preserve the
+/// visual column across vertical motion (MJB-LLR-064).
+pub fn byte_at_display_column(line: RopeSlice, target_column: usize) -> usize {
+ let len = line.len();
+ let mut column = 0;
+ let mut pos = 0;
+
+ while pos < len && column < target_column {
+ let next = next_grapheme_boundary(line, pos);
+ if next <= pos {
+ break;
+ }
+ let g = grapheme_str(line, pos..next);
+ // A line terminator is not a landing position.
+ if g.starts_with('\n') || g.starts_with('\r') {
+ break;
+ }
+ column += grapheme_width(&g, column);
+ if column > target_column {
+ break;
+ }
+ pos = next;
+ }
+ pos
+}
+
+#[cfg(test)]
+mod tests {
+ use ropey::Rope;
+
+ use super::*;
+
+ #[test]
+ fn mjb_llr_020_prev_boundary_saturates_at_zero() {
+ let r = Rope::from_str("abc");
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 0), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 1), 0);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 2);
+ }
+
+ #[test]
+ fn mjb_llr_021_next_boundary_saturates_at_end() {
+ let r = Rope::from_str("abc");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 3), 3);
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 1);
+ // Beyond the end must clamp rather than panic.
+ assert_eq!(next_grapheme_boundary(r.slice(..), 99), 3);
+ }
+
+ #[test]
+ fn mjb_llr_021_multibyte_advances_whole_char() {
+ // 文 is 3 bytes; a boundary must not land inside it.
+ let r = Rope::from_str("文字化け");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ #[test]
+ fn mjb_llr_020_combining_mark_is_one_cluster() {
+ // "e" + U+0301 COMBINING ACUTE ACCENT is a single grapheme cluster.
+ let r = Rope::from_str("e\u{0301}x");
+ assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3);
+ assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0);
+ }
+
+ /// MJB-LLR-022: boundaries must resolve identically whether or not the
+ /// cluster straddles a rope chunk edge.
+ ///
+ /// A rope large enough to hold many chunks is built from multi-byte
+ /// characters, then every boundary is walked and compared against the
+ /// contiguous `&str` answer.
+ #[test]
+ fn mjb_llr_022_boundaries_resolve_across_chunk_edges() {
+ // Large enough to force ropey to split into multiple chunks.
+ let source: String = "文字化けe\u{0301}x".repeat(4000);
+ let r = Rope::from_str(&source);
+ let s = r.slice(..);
+ assert!(
+ s.chunks().count() > 1,
+ "test is meaningless without multiple chunks"
+ );
+
+ // Walk forward over the whole rope, comparing to unicode-segmentation
+ // over the contiguous string.
+ use unicode_segmentation::UnicodeSegmentation;
+ let expected: Vec<usize> = source
+ .grapheme_indices(true)
+ .map(|(i, _)| i)
+ .chain(std::iter::once(source.len()))
+ .collect();
+
+ let mut got = vec![0usize];
+ let mut pos = 0;
+ while pos < s.len() {
+ let next = next_grapheme_boundary(s, pos);
+ assert!(next > pos, "must make progress at byte {pos}");
+ got.push(next);
+ pos = next;
+ }
+ assert_eq!(got, expected, "forward boundaries must match across chunks");
+
+ // And backward, from the end.
+ let mut back = vec![s.len()];
+ let mut pos = s.len();
+ while pos > 0 {
+ let prev = prev_grapheme_boundary(s, pos);
+ assert!(prev < pos, "must make progress backward at byte {pos}");
+ back.push(prev);
+ pos = prev;
+ }
+ back.reverse();
+ assert_eq!(back, expected, "backward boundaries must match across chunks");
+ }
+
+ #[test]
+ fn mjb_llr_023_boundary_detection() {
+ let r = Rope::from_str("文a");
+ let s = r.slice(..);
+ assert!(is_grapheme_boundary(s, 0));
+ assert!(!is_grapheme_boundary(s, 1), "inside a multi-byte char");
+ assert!(is_grapheme_boundary(s, 3));
+ assert!(is_grapheme_boundary(s, 4));
+ }
+
+ #[test]
+ fn mjb_llr_024_widths() {
+ assert_eq!(grapheme_width("a", 0), 1);
+ assert_eq!(grapheme_width("文", 0), 2, "wide char occupies two columns");
+ assert_eq!(grapheme_width("\t", 0), TAB_WIDTH);
+ assert_eq!(grapheme_width("\t", 1), TAB_WIDTH - 1, "tab fills to stop");
+ assert_eq!(grapheme_width("\u{0}", 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_counts_width_not_bytes() {
+ let r = Rope::from_str("文字a");
+ // Byte 6 is after two wide chars: four columns, not six.
+ assert_eq!(display_column(r.slice(..), 6), 4);
+ assert_eq!(display_column(r.slice(..), 0), 0);
+ }
+
+ #[test]
+ fn mjb_llr_025_display_column_tab_expands() {
+ let r = Rope::from_str("\tx");
+ assert_eq!(display_column(r.slice(..), 1), TAB_WIDTH);
+ }
+
+ #[test]
+ fn byte_at_display_column_round_trips() {
+ let r = Rope::from_str("文字a");
+ let s = r.slice(..);
+ assert_eq!(byte_at_display_column(s, 4), 6);
+ assert_eq!(byte_at_display_column(s, 0), 0);
+ // Past the end of the line clamps to the line's length.
+ assert_eq!(byte_at_display_column(s, 99), s.len());
+ }
+
+ #[test]
+ fn byte_at_display_column_stops_before_terminator() {
+ let r = Rope::from_str("ab\n");
+ assert_eq!(byte_at_display_column(r.slice(..), 99), 2);
+ }
+}