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/grapheme.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/grapheme.rs')
| -rw-r--r-- | src/buffer/grapheme.rs | 325 |
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); + } +} |
