Skip to main content

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 { &text[start..end] } else { "" }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn cursor_is_collapsed() {
129        let cursor = TextRange::cursor(5);
130        assert!(cursor.collapsed());
131        assert_eq!(cursor.length(), 0);
132        assert_eq!(cursor.start, 5);
133        assert_eq!(cursor.end, 5);
134    }
135
136    #[test]
137    fn selection_is_not_collapsed() {
138        let selection = TextRange::new(2, 7);
139        assert!(!selection.collapsed());
140        assert_eq!(selection.length(), 5);
141    }
142
143    #[test]
144    fn reverse_selection_length() {
145        let reverse = TextRange::new(7, 2);
146        assert_eq!(reverse.length(), 5);
147        assert_eq!(reverse.min(), 2);
148        assert_eq!(reverse.max(), 7);
149    }
150
151    #[test]
152    fn coerce_in_bounds() {
153        let range = TextRange::new(5, 100);
154        let coerced = range.coerce_in(10);
155        assert_eq!(coerced.start, 5);
156        assert_eq!(coerced.end, 10);
157    }
158
159    #[test]
160    fn contains_index() {
161        let range = TextRange::new(2, 5);
162        assert!(!range.contains(1));
163        assert!(range.contains(2));
164        assert!(range.contains(3));
165        assert!(range.contains(4));
166        assert!(!range.contains(5)); // exclusive end
167    }
168
169    #[test]
170    fn safe_slice_basic() {
171        let range = TextRange::new(0, 5);
172        assert_eq!(range.safe_slice("Hello World"), "Hello");
173    }
174
175    #[test]
176    fn safe_slice_beyond_bounds() {
177        let range = TextRange::new(0, 100);
178        assert_eq!(range.safe_slice("Hello"), "Hello");
179    }
180
181    #[test]
182    fn safe_slice_unicode() {
183        // "Hello 🌍" - emoji is 4 bytes
184        let text = "Hello 🌍";
185        // Range in middle of emoji (byte 7 is inside the 4-byte emoji starting at 6)
186        let range = TextRange::new(0, 7);
187        let slice = range.safe_slice(text);
188        // Should clamp to valid boundary (either before or after emoji)
189        assert!(slice == "Hello " || slice == "Hello 🌍");
190    }
191}