cranpose_foundation/text/
range.rs

1//! Text range for representing cursor position and selection.
2//!
3//! Matches Jetpack Compose's `androidx.compose.ui.text.TextRange`.
4
5/// Represents a range in text, used for cursor position and selection.
6///
7/// When `start == end`, this represents a cursor position (collapsed selection).
8/// When `start != end`, this represents a text selection.
9///
10/// # Invariants
11///
12/// - Indices are in UTF-8 byte offsets (matching Rust's `String`)
13/// - `start` can be greater than `end` for reverse selections
14/// - Use `min()` and `max()` for ordered access
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
16pub struct TextRange {
17    /// Start index of the range (can be > end for reverse selection)
18    pub start: usize,
19    /// End index of the range
20    pub end: usize,
21}
22
23impl TextRange {
24    /// Creates a new text range.
25    pub const fn new(start: usize, end: usize) -> Self {
26        Self { start, end }
27    }
28
29    /// Creates a collapsed range (cursor) at the given position.
30    pub const fn cursor(position: usize) -> Self {
31        Self {
32            start: position,
33            end: position,
34        }
35    }
36
37    /// Creates a range from 0 to 0 (cursor at start).
38    pub const fn zero() -> Self {
39        Self { start: 0, end: 0 }
40    }
41
42    /// Returns true if this range is collapsed (cursor, not selection).
43    pub const fn collapsed(&self) -> bool {
44        self.start == self.end
45    }
46
47    /// Returns the length of the selection in characters.
48    pub fn length(&self) -> usize {
49        self.end.abs_diff(self.start)
50    }
51
52    /// Returns the minimum (leftmost) index.
53    pub fn min(&self) -> usize {
54        self.start.min(self.end)
55    }
56
57    /// Returns the maximum (rightmost) index.
58    pub fn max(&self) -> usize {
59        self.start.max(self.end)
60    }
61
62    /// Returns true if this range contains the given index.
63    pub fn contains(&self, index: usize) -> bool {
64        index >= self.min() && index < self.max()
65    }
66
67    /// Coerces the range to be within [0, max].
68    pub fn coerce_in(&self, max: usize) -> Self {
69        Self {
70            start: self.start.min(max),
71            end: self.end.min(max),
72        }
73    }
74
75    /// Returns a range covering the entire text of given length.
76    pub const fn all(length: usize) -> Self {
77        Self {
78            start: 0,
79            end: length,
80        }
81    }
82
83    /// Safely slices the text, clamping to valid UTF-8 char boundaries.
84    ///
85    /// This handles edge cases where:
86    /// - Range extends beyond text length
87    /// - Range indices are not on char boundaries (e.g., in middle of multi-byte UTF-8)
88    ///
89    /// Returns an empty string if the range is invalid.
90    pub fn safe_slice<'a>(&self, text: &'a str) -> &'a str {
91        if text.is_empty() {
92            return "";
93        }
94
95        let start = self.min().min(text.len());
96        let end = self.max().min(text.len());
97
98        // Clamp start to valid char boundary (scan backward)
99        let start = if text.is_char_boundary(start) {
100            start
101        } else {
102            // Find previous char boundary by scanning backward
103            (0..start)
104                .rev()
105                .find(|&i| text.is_char_boundary(i))
106                .unwrap_or(0)
107        };
108
109        // Clamp end to valid char boundary (scan forward)
110        let end = if text.is_char_boundary(end) {
111            end
112        } else {
113            // Find next char boundary by scanning forward
114            (end..=text.len())
115                .find(|&i| text.is_char_boundary(i))
116                .unwrap_or(text.len())
117        };
118
119        if start <= end {
120            &text[start..end]
121        } else {
122            ""
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn cursor_is_collapsed() {
133        let cursor = TextRange::cursor(5);
134        assert!(cursor.collapsed());
135        assert_eq!(cursor.length(), 0);
136        assert_eq!(cursor.start, 5);
137        assert_eq!(cursor.end, 5);
138    }
139
140    #[test]
141    fn selection_is_not_collapsed() {
142        let selection = TextRange::new(2, 7);
143        assert!(!selection.collapsed());
144        assert_eq!(selection.length(), 5);
145    }
146
147    #[test]
148    fn reverse_selection_length() {
149        let reverse = TextRange::new(7, 2);
150        assert_eq!(reverse.length(), 5);
151        assert_eq!(reverse.min(), 2);
152        assert_eq!(reverse.max(), 7);
153    }
154
155    #[test]
156    fn coerce_in_bounds() {
157        let range = TextRange::new(5, 100);
158        let coerced = range.coerce_in(10);
159        assert_eq!(coerced.start, 5);
160        assert_eq!(coerced.end, 10);
161    }
162
163    #[test]
164    fn contains_index() {
165        let range = TextRange::new(2, 5);
166        assert!(!range.contains(1));
167        assert!(range.contains(2));
168        assert!(range.contains(3));
169        assert!(range.contains(4));
170        assert!(!range.contains(5)); // exclusive end
171    }
172
173    #[test]
174    fn safe_slice_basic() {
175        let range = TextRange::new(0, 5);
176        assert_eq!(range.safe_slice("Hello World"), "Hello");
177    }
178
179    #[test]
180    fn safe_slice_beyond_bounds() {
181        let range = TextRange::new(0, 100);
182        assert_eq!(range.safe_slice("Hello"), "Hello");
183    }
184
185    #[test]
186    fn safe_slice_unicode() {
187        // "Hello 🌍" - emoji is 4 bytes
188        let text = "Hello 🌍";
189        // Range in middle of emoji (byte 7 is inside the 4-byte emoji starting at 6)
190        let range = TextRange::new(0, 7);
191        let slice = range.safe_slice(text);
192        // Should clamp to valid boundary (either before or after emoji)
193        assert!(slice == "Hello " || slice == "Hello 🌍");
194    }
195}