Skip to main content

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;