//! 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::()` 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) -> 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 = 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); } }