Skip to main content

neco_textview/
lib.rs

1//! Text position primitives: line/column ↔ byte offset conversion, UTF-16 mapping,
2//! and selection/caret model.
3
4use std::fmt;
5
6/// Errors returned by text view operations.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum TextViewError {
9    InvalidRange { start: usize, end: usize },
10    OffsetOutOfBounds { offset: usize, len: usize },
11    InvalidUtf8Boundary { offset: usize },
12    LineOutOfBounds { line: u32, line_count: u32 },
13    Utf16OffsetOutOfBounds { offset: usize, total: usize },
14}
15
16impl fmt::Display for TextViewError {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::InvalidRange { start, end } => {
20                write!(f, "invalid range: start {start} > end {end}")
21            }
22            Self::OffsetOutOfBounds { offset, len } => {
23                write!(f, "offset {offset} out of bounds (len {len})")
24            }
25            Self::InvalidUtf8Boundary { offset } => {
26                write!(f, "offset {offset} is not on a UTF-8 char boundary")
27            }
28            Self::LineOutOfBounds { line, line_count } => {
29                write!(f, "line {line} out of bounds (line_count {line_count})")
30            }
31            Self::Utf16OffsetOutOfBounds { offset, total } => {
32                write!(f, "UTF-16 offset {offset} out of bounds (total {total})")
33            }
34        }
35    }
36}
37
38impl std::error::Error for TextViewError {}
39
40/// 0-based line and column position in a text document.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct Position {
43    line: u32,
44    column: u32,
45}
46
47impl Position {
48    pub const fn new(line: u32, column: u32) -> Self {
49        Self { line, column }
50    }
51
52    pub const fn line(&self) -> u32 {
53        self.line
54    }
55
56    pub const fn column(&self) -> u32 {
57        self.column
58    }
59}
60
61/// Byte offset range in a UTF-8 string. `start <= end` is enforced.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub struct TextRange {
64    start: usize,
65    end: usize,
66}
67
68impl TextRange {
69    pub fn new(start: usize, end: usize) -> Result<Self, TextViewError> {
70        if start > end {
71            return Err(TextViewError::InvalidRange { start, end });
72        }
73        Ok(Self { start, end })
74    }
75
76    pub fn empty(offset: usize) -> Self {
77        Self {
78            start: offset,
79            end: offset,
80        }
81    }
82
83    pub fn start(&self) -> usize {
84        self.start
85    }
86
87    pub fn end(&self) -> usize {
88        self.end
89    }
90
91    pub fn len(&self) -> usize {
92        self.end - self.start
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.start == self.end
97    }
98
99    pub fn contains(&self, offset: usize) -> bool {
100        self.start <= offset && offset < self.end
101    }
102
103    pub fn intersects(&self, other: &TextRange) -> bool {
104        self.start < other.end && other.start < self.end
105    }
106}
107
108/// Abstract description of one text range change.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct RangeChange {
111    start: usize,
112    old_end: usize,
113    new_end: usize,
114}
115
116impl RangeChange {
117    pub const fn new(start: usize, old_end: usize, new_end: usize) -> Self {
118        Self {
119            start,
120            old_end,
121            new_end,
122        }
123    }
124
125    pub const fn start(&self) -> usize {
126        self.start
127    }
128
129    pub const fn old_end(&self) -> usize {
130        self.old_end
131    }
132
133    pub const fn new_end(&self) -> usize {
134        self.new_end
135    }
136}
137
138/// Directional selection with anchor and head (caret position).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct Selection {
141    anchor: usize,
142    head: usize,
143}
144
145impl Selection {
146    pub const fn new(anchor: usize, head: usize) -> Self {
147        Self { anchor, head }
148    }
149
150    pub const fn cursor(offset: usize) -> Self {
151        Self {
152            anchor: offset,
153            head: offset,
154        }
155    }
156
157    pub const fn anchor(&self) -> usize {
158        self.anchor
159    }
160
161    pub const fn head(&self) -> usize {
162        self.head
163    }
164
165    /// Return the normalized range (start <= end) covered by this selection.
166    pub fn range(&self) -> TextRange {
167        if self.anchor <= self.head {
168            TextRange {
169                start: self.anchor,
170                end: self.head,
171            }
172        } else {
173            TextRange {
174                start: self.head,
175                end: self.anchor,
176            }
177        }
178    }
179
180    pub fn is_cursor(&self) -> bool {
181        self.anchor == self.head
182    }
183
184    pub fn is_forward(&self) -> bool {
185        self.anchor <= self.head
186    }
187}
188
189/// Precomputed line-start offset table for O(log n) line/column ↔ byte offset conversion.
190#[derive(Debug, Clone)]
191pub struct LineIndex {
192    line_starts: Vec<u32>,
193    len: u32,
194}
195
196impl LineIndex {
197    pub fn new(text: &str) -> Self {
198        let len = u32::try_from(text.len()).expect("text length exceeds u32::MAX");
199        let mut line_starts = vec![0u32];
200        for (i, b) in text.bytes().enumerate() {
201            if b == b'\n' {
202                let next = u32::try_from(i + 1).expect("offset exceeds u32::MAX");
203                line_starts.push(next);
204            }
205        }
206        Self { line_starts, len }
207    }
208
209    pub fn line_count(&self) -> u32 {
210        u32::try_from(self.line_starts.len()).expect("line count exceeds u32::MAX")
211    }
212
213    pub fn text_len(&self) -> u32 {
214        self.len
215    }
216
217    /// Convert a byte offset to a 0-based line/column position.
218    pub fn offset_to_position(&self, text: &str, offset: usize) -> Result<Position, TextViewError> {
219        let len = self.len as usize;
220        if offset > len {
221            return Err(TextViewError::OffsetOutOfBounds { offset, len });
222        }
223        if offset < len && !text.is_char_boundary(offset) {
224            return Err(TextViewError::InvalidUtf8Boundary { offset });
225        }
226        let line_idx = self.line_of_offset(offset)?;
227        let line_start = self.line_starts[line_idx as usize] as usize;
228        let column = u32::try_from(offset - line_start).expect("column exceeds u32::MAX");
229        Ok(Position::new(line_idx, column))
230    }
231
232    /// Convert a 0-based line/column position to a byte offset.
233    pub fn position_to_offset(
234        &self,
235        text: &str,
236        position: Position,
237    ) -> Result<usize, TextViewError> {
238        let line = position.line();
239        let lc = self.line_count();
240        if line >= lc {
241            return Err(TextViewError::LineOutOfBounds {
242                line,
243                line_count: lc,
244            });
245        }
246        let line_start = self.line_starts[line as usize] as usize;
247        let line_end = if line + 1 < lc {
248            self.line_starts[(line + 1) as usize] as usize
249        } else {
250            self.len as usize
251        };
252        let col = position.column() as usize;
253        let offset = line_start + col;
254        if offset > line_end {
255            return Err(TextViewError::OffsetOutOfBounds {
256                offset,
257                len: self.len as usize,
258            });
259        }
260        if offset < text.len() && !text.is_char_boundary(offset) {
261            return Err(TextViewError::InvalidUtf8Boundary { offset });
262        }
263        Ok(offset)
264    }
265
266    /// Return the byte range of a line excluding its trailing newline.
267    pub fn line_range(&self, line: u32) -> Result<TextRange, TextViewError> {
268        let lc = self.line_count();
269        if line >= lc {
270            return Err(TextViewError::LineOutOfBounds {
271                line,
272                line_count: lc,
273            });
274        }
275        let start = self.line_starts[line as usize] as usize;
276        let end_with_nl = if line + 1 < lc {
277            self.line_starts[(line + 1) as usize] as usize
278        } else {
279            self.len as usize
280        };
281        let end = if end_with_nl > start && line + 1 < lc {
282            end_with_nl - 1
283        } else {
284            end_with_nl
285        };
286        Ok(TextRange { start, end })
287    }
288
289    /// Return the byte range of a line including its trailing newline.
290    pub fn line_range_with_newline(&self, line: u32) -> Result<TextRange, TextViewError> {
291        let lc = self.line_count();
292        if line >= lc {
293            return Err(TextViewError::LineOutOfBounds {
294                line,
295                line_count: lc,
296            });
297        }
298        let start = self.line_starts[line as usize] as usize;
299        let end = if line + 1 < lc {
300            self.line_starts[(line + 1) as usize] as usize
301        } else {
302            self.len as usize
303        };
304        Ok(TextRange { start, end })
305    }
306
307    /// Return the 0-based line number that contains the given byte offset.
308    pub fn line_of_offset(&self, offset: usize) -> Result<u32, TextViewError> {
309        let len = self.len as usize;
310        if offset > len {
311            return Err(TextViewError::OffsetOutOfBounds { offset, len });
312        }
313        let idx = self
314            .line_starts
315            .partition_point(|&s| (s as usize) <= offset);
316        let line = if idx == 0 { 0 } else { idx - 1 };
317        Ok(u32::try_from(line).expect("line index exceeds u32::MAX"))
318    }
319}
320
321#[derive(Debug, Clone)]
322struct Utf16Anchor {
323    byte_offset: u32,
324    utf16_offset: u32,
325    byte_len: u8,
326    utf16_len: u8,
327}
328
329/// Bidirectional UTF-8 byte offset ↔ UTF-16 code unit offset mapping.
330#[derive(Debug, Clone)]
331pub struct Utf16Mapping {
332    anchors: Vec<Utf16Anchor>,
333    total_bytes: u32,
334    total_utf16: u32,
335}
336
337impl Utf16Mapping {
338    pub fn new(text: &str) -> Self {
339        let mut anchors = Vec::new();
340        let mut byte_off: u32 = 0;
341        let mut utf16_off: u32 = 0;
342
343        for ch in text.chars() {
344            let byte_len = ch.len_utf8();
345            let utf16_len = ch.len_utf16();
346
347            if byte_len != 1 {
348                anchors.push(Utf16Anchor {
349                    byte_offset: byte_off,
350                    utf16_offset: utf16_off,
351                    byte_len: u8::try_from(byte_len).expect("char byte len exceeds u8"),
352                    utf16_len: u8::try_from(utf16_len).expect("char utf16 len exceeds u8"),
353                });
354            }
355
356            byte_off += u32::try_from(byte_len).expect("byte offset exceeds u32");
357            utf16_off += u32::try_from(utf16_len).expect("utf16 offset exceeds u32");
358        }
359
360        Self {
361            anchors,
362            total_bytes: byte_off,
363            total_utf16: utf16_off,
364        }
365    }
366
367    /// Convert a UTF-8 byte offset to a UTF-16 code unit offset.
368    pub fn byte_to_utf16(&self, byte_offset: usize) -> Result<usize, TextViewError> {
369        let total = self.total_bytes as usize;
370        if byte_offset > total {
371            return Err(TextViewError::OffsetOutOfBounds {
372                offset: byte_offset,
373                len: total,
374            });
375        }
376
377        if self.anchors.is_empty() {
378            return Ok(byte_offset);
379        }
380
381        let idx = self
382            .anchors
383            .partition_point(|a| (a.byte_offset as usize) <= byte_offset);
384
385        if idx == 0 {
386            return Ok(byte_offset);
387        }
388
389        let anchor = &self.anchors[idx - 1];
390        let ab = anchor.byte_offset as usize;
391        let au = anchor.utf16_offset as usize;
392        let blen = anchor.byte_len as usize;
393        let ulen = anchor.utf16_len as usize;
394
395        if byte_offset > ab && byte_offset < ab + blen {
396            return Err(TextViewError::InvalidUtf8Boundary {
397                offset: byte_offset,
398            });
399        }
400
401        if byte_offset == ab {
402            return Ok(au);
403        }
404
405        // Anchors record only non-ASCII chars, so bytes between anchors are ASCII
406        let ascii_past = byte_offset - (ab + blen);
407        Ok(au + ulen + ascii_past)
408    }
409
410    /// Convert a UTF-16 code unit offset to a UTF-8 byte offset.
411    pub fn utf16_to_byte(&self, utf16_offset: usize) -> Result<usize, TextViewError> {
412        let total = self.total_utf16 as usize;
413        if utf16_offset > total {
414            return Err(TextViewError::Utf16OffsetOutOfBounds {
415                offset: utf16_offset,
416                total,
417            });
418        }
419
420        if self.anchors.is_empty() {
421            return Ok(utf16_offset);
422        }
423
424        let idx = self
425            .anchors
426            .partition_point(|a| (a.utf16_offset as usize) <= utf16_offset);
427
428        if idx == 0 {
429            return Ok(utf16_offset);
430        }
431
432        let anchor = &self.anchors[idx - 1];
433        let ab = anchor.byte_offset as usize;
434        let au = anchor.utf16_offset as usize;
435        let blen = anchor.byte_len as usize;
436        let ulen = anchor.utf16_len as usize;
437
438        if utf16_offset > au && utf16_offset < au + ulen {
439            return Err(TextViewError::Utf16OffsetOutOfBounds {
440                offset: utf16_offset,
441                total,
442            });
443        }
444
445        if utf16_offset == au {
446            return Ok(ab);
447        }
448
449        let ascii_past = utf16_offset - (au + ulen);
450        Ok(ab + blen + ascii_past)
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn position_new() {
460        let p = Position::new(3, 7);
461        assert_eq!(p.line(), 3);
462        assert_eq!(p.column(), 7);
463    }
464
465    #[test]
466    fn text_range_new_ok() {
467        let r = TextRange::new(2, 5).unwrap();
468        assert_eq!(r.start(), 2);
469        assert_eq!(r.end(), 5);
470        assert_eq!(r.len(), 3);
471        assert!(!r.is_empty());
472    }
473
474    #[test]
475    fn text_range_new_reversed() {
476        let err = TextRange::new(5, 2).unwrap_err();
477        assert_eq!(err, TextViewError::InvalidRange { start: 5, end: 2 });
478    }
479
480    #[test]
481    fn text_range_empty() {
482        let r = TextRange::empty(10);
483        assert_eq!(r.start(), 10);
484        assert_eq!(r.end(), 10);
485        assert!(r.is_empty());
486        assert_eq!(r.len(), 0);
487    }
488
489    #[test]
490    fn text_range_contains() {
491        let r = TextRange::new(2, 5).unwrap();
492        assert!(r.contains(2));
493        assert!(r.contains(4));
494        assert!(!r.contains(5));
495        assert!(!r.contains(1));
496    }
497
498    #[test]
499    fn text_range_intersects() {
500        let a = TextRange::new(2, 5).unwrap();
501        let b = TextRange::new(4, 8).unwrap();
502        let c = TextRange::new(5, 8).unwrap();
503        assert!(a.intersects(&b));
504        assert!(!a.intersects(&c));
505        let e1 = TextRange::empty(3);
506        let e2 = TextRange::empty(3);
507        assert!(!e1.intersects(&e2));
508    }
509
510    #[test]
511    fn range_change_new() {
512        let change = RangeChange::new(2, 5, 8);
513        assert_eq!(change.start(), 2);
514        assert_eq!(change.old_end(), 5);
515        assert_eq!(change.new_end(), 8);
516    }
517
518    #[test]
519    fn range_change_start() {
520        let change = RangeChange::new(3, 7, 9);
521        assert_eq!(change.start(), 3);
522    }
523
524    #[test]
525    fn range_change_old_end() {
526        let change = RangeChange::new(3, 7, 9);
527        assert_eq!(change.old_end(), 7);
528    }
529
530    #[test]
531    fn range_change_new_end() {
532        let change = RangeChange::new(3, 7, 9);
533        assert_eq!(change.new_end(), 9);
534    }
535
536    #[test]
537    fn selection_cursor() {
538        let s = Selection::cursor(5);
539        assert!(s.is_cursor());
540        assert_eq!(s.anchor(), 5);
541        assert_eq!(s.head(), 5);
542        assert!(s.is_forward());
543        let r = s.range();
544        assert!(r.is_empty());
545    }
546
547    #[test]
548    fn selection_forward() {
549        let s = Selection::new(2, 8);
550        assert!(!s.is_cursor());
551        assert!(s.is_forward());
552        let r = s.range();
553        assert_eq!(r.start(), 2);
554        assert_eq!(r.end(), 8);
555    }
556
557    #[test]
558    fn selection_backward() {
559        let s = Selection::new(8, 2);
560        assert!(!s.is_cursor());
561        assert!(!s.is_forward());
562        let r = s.range();
563        assert_eq!(r.start(), 2);
564        assert_eq!(r.end(), 8);
565    }
566
567    #[test]
568    fn line_index_empty_text() {
569        let text = "";
570        let idx = LineIndex::new(text);
571        assert_eq!(idx.line_count(), 1);
572        assert_eq!(idx.text_len(), 0);
573
574        let pos = idx.offset_to_position(text, 0).unwrap();
575        assert_eq!(pos, Position::new(0, 0));
576
577        let off = idx.position_to_offset(text, Position::new(0, 0)).unwrap();
578        assert_eq!(off, 0);
579    }
580
581    #[test]
582    fn line_index_single_line() {
583        let text = "hello";
584        let idx = LineIndex::new(text);
585        assert_eq!(idx.line_count(), 1);
586        assert_eq!(idx.text_len(), 5);
587
588        let pos = idx.offset_to_position(text, 3).unwrap();
589        assert_eq!(pos, Position::new(0, 3));
590
591        let off = idx.position_to_offset(text, pos).unwrap();
592        assert_eq!(off, 3);
593
594        let pos_end = idx.offset_to_position(text, 5).unwrap();
595        assert_eq!(pos_end, Position::new(0, 5));
596    }
597
598    #[test]
599    fn line_index_multi_line() {
600        let text = "abc\ndef\nghi";
601        let idx = LineIndex::new(text);
602        assert_eq!(idx.line_count(), 3);
603        assert_eq!(idx.text_len(), 11);
604
605        let pos = idx.offset_to_position(text, 4).unwrap();
606        assert_eq!(pos, Position::new(1, 0));
607        let off = idx.position_to_offset(text, pos).unwrap();
608        assert_eq!(off, 4);
609
610        let pos2 = idx.offset_to_position(text, 9).unwrap();
611        assert_eq!(pos2, Position::new(2, 1));
612        let off2 = idx.position_to_offset(text, pos2).unwrap();
613        assert_eq!(off2, 9);
614    }
615
616    #[test]
617    fn line_index_multibyte() {
618        let text = "あいう\nえお";
619        let idx = LineIndex::new(text);
620        assert_eq!(idx.line_count(), 2);
621
622        let pos = idx.offset_to_position(text, 10).unwrap();
623        assert_eq!(pos, Position::new(1, 0));
624        let off = idx.position_to_offset(text, pos).unwrap();
625        assert_eq!(off, 10);
626
627        let pos2 = idx.offset_to_position(text, 3).unwrap();
628        assert_eq!(pos2, Position::new(0, 3));
629        let off2 = idx.position_to_offset(text, pos2).unwrap();
630        assert_eq!(off2, 3);
631    }
632
633    #[test]
634    fn line_index_roundtrip() {
635        let text = "hello\nworld\n";
636        let idx = LineIndex::new(text);
637
638        for offset in 0..=text.len() {
639            if text.is_char_boundary(offset) {
640                let pos = idx.offset_to_position(text, offset).unwrap();
641                let back = idx.position_to_offset(text, pos).unwrap();
642                assert_eq!(back, offset, "roundtrip failed at offset {offset}");
643            }
644        }
645    }
646
647    #[test]
648    fn line_index_offset_out_of_bounds() {
649        let text = "abc";
650        let idx = LineIndex::new(text);
651        let err = idx.offset_to_position(text, 10).unwrap_err();
652        assert_eq!(err, TextViewError::OffsetOutOfBounds { offset: 10, len: 3 });
653    }
654
655    #[test]
656    fn line_index_line_out_of_bounds() {
657        let text = "abc\ndef";
658        let idx = LineIndex::new(text);
659        let err = idx.line_range(5).unwrap_err();
660        assert_eq!(
661            err,
662            TextViewError::LineOutOfBounds {
663                line: 5,
664                line_count: 2
665            }
666        );
667    }
668
669    #[test]
670    fn line_index_invalid_utf8_boundary() {
671        let text = "あ";
672        let idx = LineIndex::new(text);
673        let err = idx.offset_to_position(text, 1).unwrap_err();
674        assert_eq!(err, TextViewError::InvalidUtf8Boundary { offset: 1 });
675    }
676
677    #[test]
678    fn line_index_line_range() {
679        let text = "abc\ndef\n";
680        let idx = LineIndex::new(text);
681
682        let r0 = idx.line_range(0).unwrap();
683        assert_eq!(r0.start(), 0);
684        assert_eq!(r0.end(), 3);
685
686        let r1 = idx.line_range(1).unwrap();
687        assert_eq!(r1.start(), 4);
688        assert_eq!(r1.end(), 7);
689
690        let r2 = idx.line_range(2).unwrap();
691        assert_eq!(r2.start(), 8);
692        assert_eq!(r2.end(), 8);
693
694        let rn0 = idx.line_range_with_newline(0).unwrap();
695        assert_eq!(rn0.start(), 0);
696        assert_eq!(rn0.end(), 4);
697
698        let rn1 = idx.line_range_with_newline(1).unwrap();
699        assert_eq!(rn1.start(), 4);
700        assert_eq!(rn1.end(), 8);
701    }
702
703    #[test]
704    fn line_index_line_of_offset() {
705        let text = "abc\ndef\nghi";
706        let idx = LineIndex::new(text);
707        assert_eq!(idx.line_of_offset(0).unwrap(), 0);
708        assert_eq!(idx.line_of_offset(3).unwrap(), 0);
709        assert_eq!(idx.line_of_offset(4).unwrap(), 1);
710        assert_eq!(idx.line_of_offset(8).unwrap(), 2);
711        assert_eq!(idx.line_of_offset(11).unwrap(), 2);
712    }
713
714    #[test]
715    fn utf16_mapping_ascii() {
716        let text = "hello world";
717        let m = Utf16Mapping::new(text);
718
719        for i in 0..=text.len() {
720            assert_eq!(m.byte_to_utf16(i).unwrap(), i);
721            assert_eq!(m.utf16_to_byte(i).unwrap(), i);
722        }
723    }
724
725    #[test]
726    fn utf16_mapping_japanese() {
727        let text = "aあb";
728        let m = Utf16Mapping::new(text);
729
730        assert_eq!(m.byte_to_utf16(0).unwrap(), 0);
731        assert_eq!(m.byte_to_utf16(1).unwrap(), 1);
732        assert!(m.byte_to_utf16(2).is_err());
733        assert!(m.byte_to_utf16(3).is_err());
734        assert_eq!(m.byte_to_utf16(4).unwrap(), 2);
735        assert_eq!(m.byte_to_utf16(5).unwrap(), 3);
736
737        assert_eq!(m.utf16_to_byte(0).unwrap(), 0);
738        assert_eq!(m.utf16_to_byte(1).unwrap(), 1);
739        assert_eq!(m.utf16_to_byte(2).unwrap(), 4);
740        assert_eq!(m.utf16_to_byte(3).unwrap(), 5);
741    }
742
743    #[test]
744    fn utf16_mapping_surrogate_pair() {
745        let text = "a😀b";
746        let m = Utf16Mapping::new(text);
747
748        assert_eq!(m.byte_to_utf16(0).unwrap(), 0);
749        assert_eq!(m.byte_to_utf16(1).unwrap(), 1);
750        assert!(m.byte_to_utf16(2).is_err());
751        assert!(m.byte_to_utf16(3).is_err());
752        assert!(m.byte_to_utf16(4).is_err());
753        assert_eq!(m.byte_to_utf16(5).unwrap(), 3);
754        assert_eq!(m.byte_to_utf16(6).unwrap(), 4);
755
756        assert_eq!(m.utf16_to_byte(0).unwrap(), 0);
757        assert_eq!(m.utf16_to_byte(1).unwrap(), 1);
758        assert!(m.utf16_to_byte(2).is_err());
759        assert_eq!(m.utf16_to_byte(3).unwrap(), 5);
760        assert_eq!(m.utf16_to_byte(4).unwrap(), 6);
761    }
762
763    #[test]
764    fn utf16_mapping_roundtrip() {
765        let text = "Hello あいう 😀🎉 world";
766        let m = Utf16Mapping::new(text);
767
768        let mut byte_off = 0;
769        for ch in text.chars() {
770            let u16_off = m.byte_to_utf16(byte_off).unwrap();
771            let back = m.utf16_to_byte(u16_off).unwrap();
772            assert_eq!(back, byte_off, "roundtrip failed at byte {byte_off}");
773            byte_off += ch.len_utf8();
774        }
775        let u16_end = m.byte_to_utf16(byte_off).unwrap();
776        let back_end = m.utf16_to_byte(u16_end).unwrap();
777        assert_eq!(back_end, byte_off);
778    }
779
780    #[test]
781    fn utf16_mapping_out_of_bounds() {
782        let text = "abc";
783        let m = Utf16Mapping::new(text);
784
785        assert!(m.byte_to_utf16(10).is_err());
786        assert!(m.utf16_to_byte(10).is_err());
787    }
788
789    #[test]
790    fn error_display() {
791        let e = TextViewError::InvalidRange { start: 5, end: 2 };
792        let s = e.to_string();
793        assert!(s.contains("5"));
794        assert!(s.contains("2"));
795    }
796}