1use crate::GlyphPosition;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Selection {
13 pub anchor: usize,
15 pub focus: usize,
17}
18
19impl Selection {
20 pub fn new(offset: usize) -> Self {
22 Self {
23 anchor: offset,
24 focus: offset,
25 }
26 }
27
28 pub fn extend_to(&mut self, focus: usize) {
30 self.focus = focus;
31 }
32
33 pub fn is_collapsed(&self) -> bool {
35 self.anchor == self.focus
36 }
37
38 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 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 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 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 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 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 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 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 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 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 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 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 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 return last + line.last().map(|g| g.width.round() as usize).unwrap_or(1);
196 }
197 }
198 byte_offset
199 }
200}
201
202#[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 assert_eq!(Selection::extend_word_forward("hello world", 0), 5);
233 }
234
235 #[test]
236 fn selection_extend_word_forward_skips_whitespace() {
237 assert_eq!(Selection::extend_word_forward("hello world", 5), 11);
239 }
240
241 #[test]
242 fn selection_extend_word_backward_basic() {
243 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 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}