Skip to main content

oxiui_text/
selection.rs

1//! Text selection model: anchor/focus positions, byte↔grapheme mapping,
2//! highlight rect computation, and word/line boundary navigation.
3
4use crate::GlyphPosition;
5
6// ── Selection ─────────────────────────────────────────────────────────────────
7
8/// A text selection defined by two byte offsets into the source string.
9///
10/// When `anchor == focus` the selection is a collapsed caret.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Selection {
13    /// Byte offset where the selection started (the fixed end).
14    pub anchor: usize,
15    /// Byte offset of the current cursor / the moving end.
16    pub focus: usize,
17}
18
19impl Selection {
20    /// Create a collapsed selection (caret) at `offset`.
21    pub fn new(offset: usize) -> Self {
22        Self {
23            anchor: offset,
24            focus: offset,
25        }
26    }
27
28    /// Extend the selection so that the focus moves to `focus`.
29    pub fn extend_to(&mut self, focus: usize) {
30        self.focus = focus;
31    }
32
33    /// Returns `true` when anchor and focus are at the same position.
34    pub fn is_collapsed(&self) -> bool {
35        self.anchor == self.focus
36    }
37
38    /// Returns `(start, end)` in ascending byte-offset order.
39    pub fn normalized(&self) -> (usize, usize) {
40        if self.anchor <= self.focus {
41            (self.anchor, self.focus)
42        } else {
43            (self.focus, self.anchor)
44        }
45    }
46
47    // ── Grapheme ↔ Byte conversion ────────────────────────────────────────
48
49    /// Convert a UTF-8 byte offset to a grapheme cluster index.
50    ///
51    /// A "grapheme cluster" here is defined as a sequence of bytes whose
52    /// first byte has the form `0b0xxx_xxxx` (ASCII) or `0b11xx_xxxx`
53    /// (leading multibyte byte).  This is a simplified model; for full
54    /// Unicode grapheme cluster segmentation use the `unicode-segmentation`
55    /// crate (not a dependency here per the Pure Rust / no-extra-crate
56    /// policy).
57    pub fn byte_to_grapheme(text: &str, byte_offset: usize) -> usize {
58        let capped = byte_offset.min(text.len());
59        text[..capped].char_indices().count()
60    }
61
62    /// Convert a grapheme cluster index to the byte offset of its first byte.
63    pub fn grapheme_to_byte(text: &str, grapheme_idx: usize) -> usize {
64        text.char_indices()
65            .nth(grapheme_idx)
66            .map(|(i, _)| i)
67            .unwrap_or(text.len())
68    }
69
70    // ── Highlight rects ───────────────────────────────────────────────────
71
72    /// Compute highlight rectangles for the selected region.
73    ///
74    /// Returns `Vec<(x, y, w, h)>` — one rect per line that is (even
75    /// partially) covered by the selection.
76    pub fn highlight_rects(
77        &self,
78        glyphs: &[Vec<GlyphPosition>],
79        line_height: f32,
80    ) -> Vec<(f32, f32, f32, f32)> {
81        if self.is_collapsed() {
82            return Vec::new();
83        }
84        let (sel_start, sel_end) = self.normalized();
85        let mut rects: Vec<(f32, f32, f32, f32)> = Vec::new();
86
87        for line in glyphs {
88            if line.is_empty() {
89                continue;
90            }
91            // Find glyphs that overlap the selection range.
92            let mut x_start: Option<f32> = None;
93            let mut x_end = 0.0_f32;
94            let mut line_y = 0.0_f32;
95
96            for glyph in line {
97                if glyph.byte_offset >= sel_end {
98                    break;
99                }
100                if glyph.byte_offset >= sel_start {
101                    if x_start.is_none() {
102                        x_start = Some(glyph.x);
103                        line_y = glyph.y;
104                    }
105                    x_end = glyph.x + glyph.width;
106                }
107            }
108
109            if let Some(x0) = x_start {
110                let w = (x_end - x0).max(1.0);
111                rects.push((x0, line_y, w, line_height));
112            }
113        }
114        rects
115    }
116
117    // ── Word navigation ───────────────────────────────────────────────────
118
119    /// Return the byte offset just past the end of the word that starts at
120    /// or after `byte_offset`.
121    pub fn extend_word_forward(text: &str, byte_offset: usize) -> usize {
122        if byte_offset >= text.len() {
123            return text.len();
124        }
125        let rest = &text[byte_offset..];
126        // Skip leading whitespace first.
127        let leading: usize = rest
128            .char_indices()
129            .take_while(|(_, c)| c.is_whitespace())
130            .last()
131            .map(|(i, c)| i + c.len_utf8())
132            .unwrap_or(0);
133        // Then skip to end of next non-whitespace word.
134        let word_end: usize = rest[leading..]
135            .char_indices()
136            .take_while(|(_, c)| !c.is_whitespace())
137            .last()
138            .map(|(i, c)| i + c.len_utf8())
139            .unwrap_or(0);
140        byte_offset + leading + word_end
141    }
142
143    /// Return the byte offset of the start of the word at or before
144    /// `byte_offset`.
145    pub fn extend_word_backward(text: &str, byte_offset: usize) -> usize {
146        let capped = byte_offset.min(text.len());
147        let before = &text[..capped];
148        // Skip trailing whitespace.
149        let trailing: usize = before
150            .char_indices()
151            .rev()
152            .take_while(|(_, c)| c.is_whitespace())
153            .last()
154            .map(|(i, _)| i)
155            .unwrap_or(capped);
156        // Then walk back to the start of the preceding non-whitespace word.
157        let word_start: usize = before[..trailing]
158            .char_indices()
159            .rev()
160            .take_while(|(_, c)| !c.is_whitespace())
161            .last()
162            .map(|(i, _)| i)
163            .unwrap_or(0);
164        word_start
165    }
166
167    // ── Line navigation ───────────────────────────────────────────────────
168
169    /// Return the byte offset of the first glyph on the line that contains
170    /// `byte_offset`.
171    pub fn extend_line_start(glyphs: &[Vec<GlyphPosition>], byte_offset: usize) -> usize {
172        for line in glyphs {
173            let offsets: Vec<usize> = line.iter().map(|g| g.byte_offset).collect();
174            if offsets.contains(&byte_offset)
175                || (offsets.first().copied().unwrap_or(usize::MAX) <= byte_offset
176                    && offsets.last().copied().unwrap_or(0) >= byte_offset)
177            {
178                return offsets.first().copied().unwrap_or(0);
179            }
180        }
181        0
182    }
183
184    /// Return the byte offset past the last glyph on the line that contains
185    /// `byte_offset`.
186    pub fn extend_line_end(glyphs: &[Vec<GlyphPosition>], byte_offset: usize) -> usize {
187        for line in glyphs {
188            if line.is_empty() {
189                continue;
190            }
191            let first = line.first().map(|g| g.byte_offset).unwrap_or(usize::MAX);
192            let last = line.last().map(|g| g.byte_offset).unwrap_or(0);
193            if first <= byte_offset && byte_offset <= last {
194                // Offset past the last glyph on this line.
195                return last + line.last().map(|g| g.width.round() as usize).unwrap_or(1);
196            }
197        }
198        byte_offset
199    }
200}
201
202// ── Tests ─────────────────────────────────────────────────────────────────────
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn selection_collapsed_is_caret() {
210        assert!(Selection::new(5).is_collapsed());
211    }
212
213    #[test]
214    fn selection_extend_not_collapsed() {
215        let mut sel = Selection::new(0);
216        sel.extend_to(5);
217        assert!(!sel.is_collapsed());
218    }
219
220    #[test]
221    fn selection_normalized_order() {
222        let sel = Selection {
223            anchor: 10,
224            focus: 3,
225        };
226        assert_eq!(sel.normalized(), (3, 10));
227    }
228
229    #[test]
230    fn selection_extend_forward_to_word() {
231        // "hello world" — start at 0 → forward to end of "hello" = 5
232        assert_eq!(Selection::extend_word_forward("hello world", 0), 5);
233    }
234
235    #[test]
236    fn selection_extend_word_forward_skips_whitespace() {
237        // Start at 5 (space) → skip space, skip "world" → 11
238        assert_eq!(Selection::extend_word_forward("hello world", 5), 11);
239    }
240
241    #[test]
242    fn selection_extend_word_backward_basic() {
243        // "hello world", offset=11 → start of "world" = 6
244        assert_eq!(Selection::extend_word_backward("hello world", 11), 6);
245    }
246
247    #[test]
248    fn selection_grapheme_byte_roundtrip() {
249        let text = "héllo";
250        for (byte_off, _) in text.char_indices() {
251            let g = Selection::byte_to_grapheme(text, byte_off);
252            let recovered = Selection::grapheme_to_byte(text, g);
253            assert_eq!(recovered, byte_off);
254        }
255    }
256
257    #[test]
258    fn selection_highlight_rect_count() {
259        // Build a fake single-line layout.
260        let line = vec![
261            GlyphPosition {
262                byte_offset: 0,
263                x: 0.0,
264                y: 0.0,
265                width: 10.0,
266                height: 16.0,
267            },
268            GlyphPosition {
269                byte_offset: 1,
270                x: 10.0,
271                y: 0.0,
272                width: 10.0,
273                height: 16.0,
274            },
275            GlyphPosition {
276                byte_offset: 2,
277                x: 20.0,
278                y: 0.0,
279                width: 10.0,
280                height: 16.0,
281            },
282        ];
283        let glyphs = vec![line];
284        let sel = Selection {
285            anchor: 0,
286            focus: 3,
287        };
288        let rects = sel.highlight_rects(&glyphs, 16.0);
289        assert!(!rects.is_empty(), "non-empty selection must yield ≥1 rect");
290    }
291
292    #[test]
293    fn selection_collapsed_no_highlight() {
294        let glyphs: Vec<Vec<GlyphPosition>> = vec![vec![GlyphPosition {
295            byte_offset: 0,
296            x: 0.0,
297            y: 0.0,
298            width: 10.0,
299            height: 16.0,
300        }]];
301        let sel = Selection::new(0);
302        assert!(sel.highlight_rects(&glyphs, 16.0).is_empty());
303    }
304}