cranpose_foundation/text/range.rs
1/// Represents a range in text, used for cursor position and selection.
2///
3/// When `start == end`, this represents a cursor position (collapsed selection).
4/// When `start != end`, this represents a text selection.
5///
6/// # Invariants
7///
8/// - Indices are in UTF-8 byte offsets (matching Rust's `String`)
9/// - `start` can be greater than `end` for reverse selections
10/// - Use `min()` and `max()` for ordered access
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
12pub struct TextRange {
13 /// Start index of the range (can be > end for reverse selection)
14 pub start: usize,
15 /// End index of the range
16 pub end: usize,
17}
18
19impl TextRange {
20 /// Creates a new text range.
21 pub const fn new(start: usize, end: usize) -> Self {
22 Self { start, end }
23 }
24
25 /// Creates a collapsed range (cursor) at the given position.
26 pub const fn cursor(position: usize) -> Self {
27 Self {
28 start: position,
29 end: position,
30 }
31 }
32
33 /// Creates a range from 0 to 0 (cursor at start).
34 pub const fn zero() -> Self {
35 Self { start: 0, end: 0 }
36 }
37
38 /// Returns true if this range is collapsed (cursor, not selection).
39 pub const fn collapsed(&self) -> bool {
40 self.start == self.end
41 }
42
43 /// Returns the length of the selection in characters.
44 pub fn length(&self) -> usize {
45 self.end.abs_diff(self.start)
46 }
47
48 /// Returns the minimum (leftmost) index.
49 pub fn min(&self) -> usize {
50 self.start.min(self.end)
51 }
52
53 /// Returns the maximum (rightmost) index.
54 pub fn max(&self) -> usize {
55 self.start.max(self.end)
56 }
57
58 /// Returns true if this range contains the given index.
59 pub fn contains(&self, index: usize) -> bool {
60 index >= self.min() && index < self.max()
61 }
62
63 /// Coerces the range to be within [0, max].
64 pub fn coerce_in(&self, max: usize) -> Self {
65 Self {
66 start: self.start.min(max),
67 end: self.end.min(max),
68 }
69 }
70
71 /// Returns a range covering the entire text of given length.
72 pub const fn all(length: usize) -> Self {
73 Self {
74 start: 0,
75 end: length,
76 }
77 }
78
79 /// Safely slices the text, clamping to valid UTF-8 char boundaries.
80 ///
81 /// This handles edge cases where:
82 /// - Range extends beyond text length
83 /// - Range indices are not on char boundaries (e.g., in middle of multi-byte UTF-8)
84 ///
85 /// Returns an empty string if the range is invalid.
86 pub fn safe_slice<'a>(&self, text: &'a str) -> &'a str {
87 if text.is_empty() {
88 return "";
89 }
90
91 let start = self.min().min(text.len());
92 let end = self.max().min(text.len());
93
94 let start = if text.is_char_boundary(start) {
95 start
96 } else {
97 (0..start)
98 .rev()
99 .find(|&i| text.is_char_boundary(i))
100 .unwrap_or(0)
101 };
102
103 let end = if text.is_char_boundary(end) {
104 end
105 } else {
106 (end..=text.len())
107 .find(|&i| text.is_char_boundary(i))
108 .unwrap_or(text.len())
109 };
110
111 if start <= end { &text[start..end] } else { "" }
112 }
113}
114
115#[cfg(test)]
116#[path = "tests/range_tests.rs"]
117mod tests;