1use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
6
7use crate::text3::cache::{
8 is_word_char, PositionedItem, ShapedCluster, ShapedItem, UnifiedLayout,
9};
10
11#[must_use] pub fn select_word_at_cursor(
16 cursor: &TextCursor,
17 layout: &UnifiedLayout,
18) -> Option<SelectionRange> {
19 let (item_idx, _cluster) = find_cluster_at_cursor(cursor, layout)?;
21
22 let (line_text, cluster_map) = extract_line_text_and_clusters(item_idx, layout);
24
25 let cursor_byte_offset = cluster_map
27 .iter()
28 .take_while(|(id, _)| *id != cursor.cluster_id)
29 .map(|(_, len)| len)
30 .sum::<usize>();
31
32 let (word_start, word_end) = find_word_boundaries(&line_text, cursor_byte_offset);
34
35 let start_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_start)?;
37 let end_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_end.saturating_sub(1))
38 .unwrap_or(start_cluster_id);
39
40 Some(SelectionRange {
41 start: TextCursor {
42 cluster_id: start_cluster_id,
43 affinity: CursorAffinity::Leading,
44 },
45 end: TextCursor {
46 cluster_id: end_cluster_id,
47 affinity: CursorAffinity::Trailing,
48 },
49 })
50}
51
52#[must_use] pub fn select_paragraph_at_cursor(
57 cursor: &TextCursor,
58 layout: &UnifiedLayout,
59) -> Option<SelectionRange> {
60 let (item_idx, _) = find_cluster_at_cursor(cursor, layout)?;
62 let item = &layout.items[item_idx];
63 let line_index = item.line_index;
64
65 let line_items: Vec<(usize, &PositionedItem)> = layout
67 .items
68 .iter()
69 .enumerate()
70 .filter(|(_, item)| item.line_index == line_index)
71 .collect();
72
73 if line_items.is_empty() {
74 return None;
75 }
76
77 let first_cluster = line_items
79 .iter()
80 .find_map(|(_, item)| item.item.as_cluster())?;
81
82 let last_cluster = line_items
83 .iter()
84 .rev()
85 .find_map(|(_, item)| item.item.as_cluster())?;
86
87 Some(SelectionRange {
89 start: TextCursor {
90 cluster_id: first_cluster.source_cluster_id,
91 affinity: CursorAffinity::Leading,
92 },
93 end: TextCursor {
94 cluster_id: last_cluster.source_cluster_id,
95 affinity: CursorAffinity::Trailing,
96 },
97 })
98}
99
100fn find_cluster_at_cursor<'a>(
104 cursor: &TextCursor,
105 layout: &'a UnifiedLayout,
106) -> Option<(usize, &'a ShapedCluster)> {
107 layout.items.iter().enumerate().find_map(|(idx, item)| {
108 if let ShapedItem::Cluster(cluster) = &item.item {
109 if cluster.source_cluster_id == cursor.cluster_id {
110 return Some((idx, cluster));
111 }
112 }
113 None
114 })
115}
116
117fn extract_line_text_and_clusters(
131 item_idx: usize,
132 layout: &UnifiedLayout,
133) -> (String, Vec<(GraphemeClusterId, usize)>) {
134 let Some(source_run) = layout.items[item_idx]
135 .item
136 .as_cluster()
137 .map(|c| c.source_cluster_id.source_run)
138 else {
139 return (String::new(), Vec::new());
140 };
141
142 let mut clusters: Vec<&ShapedCluster> = layout
145 .items
146 .iter()
147 .filter_map(|item| item.item.as_cluster())
148 .filter(|c| c.source_cluster_id.source_run == source_run)
149 .collect();
150 clusters.sort_by_key(|c| c.source_cluster_id.start_byte_in_run);
151
152 let mut text = String::new();
153 let mut cluster_map = Vec::new();
154 for c in clusters {
155 let s = c.text.as_str();
156 cluster_map.push((c.source_cluster_id, s.len()));
157 text.push_str(s);
158 }
159
160 (text, cluster_map)
161}
162
163fn byte_offset_to_cluster_id(
165 cluster_map: &[(GraphemeClusterId, usize)],
166 byte_offset: usize,
167) -> Option<GraphemeClusterId> {
168 let mut cumulative = 0;
169 for (id, len) in cluster_map {
170 if byte_offset < cumulative + len {
171 return Some(*id);
172 }
173 cumulative += len;
174 }
175 cluster_map.last().map(|(id, _)| *id)
176}
177
178fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
183 let cursor_offset = cursor_offset.min(text.len());
185
186 let mut word_start = 0;
188 let char_indices: Vec<(usize, char)> = text.char_indices().collect();
189
190 for (i, (byte_idx, ch)) in char_indices.iter().enumerate().rev() {
191 if *byte_idx >= cursor_offset {
192 continue;
193 }
194
195 if !is_word_char(*ch) {
196 word_start = if i + 1 < char_indices.len() {
198 char_indices[i + 1].0
199 } else {
200 text.len()
201 };
202 break;
203 }
204 }
205
206 let mut word_end = text.len();
208 for (byte_idx, ch) in &char_indices {
209 if *byte_idx <= cursor_offset {
210 continue;
211 }
212
213 if !is_word_char(*ch) {
214 word_end = *byte_idx;
216 break;
217 }
218 }
219
220 if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
222 if !is_word_char(*ch) {
223 let start = char_indices
225 .iter()
226 .rev()
227 .find(|(idx, c)| *idx < cursor_offset && is_word_char(*c))
228 .map_or(0, |(idx, c)| idx + c.len_utf8());
229
230 let end = char_indices
231 .iter()
232 .find(|(idx, c)| *idx > cursor_offset && is_word_char(*c))
233 .map_or(text.len(), |(idx, _)| *idx);
234
235 return (start, end);
236 }
237 }
238
239 (word_start, word_end)
240}
241
242#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_word_boundaries_simple() {
253 let text = "Hello World";
254 let (start, end) = find_word_boundaries(text, 2);
255 assert_eq!(&text[start..end], "Hello");
256
257 let (start, end) = find_word_boundaries(text, 7);
258 assert_eq!(&text[start..end], "World");
259
260 let (start, end) = find_word_boundaries(text, 5);
261 assert_eq!(&text[start..end], " ");
262 }
263
264 #[test]
265 fn test_word_boundaries_start_end() {
266 let text = "Hello";
267 let (start, end) = find_word_boundaries(text, 0);
268 assert_eq!(&text[start..end], "Hello");
269
270 let (start, end) = find_word_boundaries(text, 5);
271 assert_eq!(&text[start..end], "Hello");
272 }
273
274 #[test]
275 fn test_word_boundaries_punctuation() {
276 let text = "Hello, World!";
277 let (start, end) = find_word_boundaries(text, 2);
278 assert_eq!(&text[start..end], "Hello");
279
280 let (start, end) = find_word_boundaries(text, 5);
281 assert_eq!(&text[start..end], ", ");
282
283 let (start, end) = find_word_boundaries(text, 8);
284 assert_eq!(&text[start..end], "World");
285 }
286
287 #[test]
288 fn test_word_boundaries_underscore() {
289 let text = "hello_world";
290 let (start, end) = find_word_boundaries(text, 5);
291 assert_eq!(&text[start..end], "hello_world");
292 }
293
294 #[test]
295 fn test_is_word_char() {
296 assert!(is_word_char('a'));
297 assert!(is_word_char('Z'));
298 assert!(is_word_char('0'));
299 assert!(is_word_char('_'));
300 assert!(!is_word_char(' '));
301 assert!(!is_word_char(','));
302 assert!(!is_word_char('!'));
303 }
304
305 #[test]
306 fn test_word_boundaries_empty() {
307 let (start, end) = find_word_boundaries("", 0);
308 assert_eq!(start, 0);
309 assert_eq!(end, 0);
310 }
311
312 #[test]
313 fn test_byte_offset_to_cluster_id_basic() {
314 let id0 = GraphemeClusterId { source_run: 0, start_byte_in_run: 0 };
315 let id1 = GraphemeClusterId { source_run: 0, start_byte_in_run: 5 };
316 let id2 = GraphemeClusterId { source_run: 0, start_byte_in_run: 6 };
317 let map = vec![(id0, 5), (id1, 1), (id2, 5)];
318
319 assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(id0));
320 assert_eq!(byte_offset_to_cluster_id(&map, 4), Some(id0));
321 assert_eq!(byte_offset_to_cluster_id(&map, 5), Some(id1));
322 assert_eq!(byte_offset_to_cluster_id(&map, 6), Some(id2));
323 assert_eq!(byte_offset_to_cluster_id(&map, 100), Some(id2));
324 }
325}
326
327#[cfg(test)]
339#[allow(
340 clippy::cast_possible_truncation,
341 clippy::similar_names,
342 clippy::too_many_lines
343)]
344mod autotest_generated {
345 use std::sync::Arc;
346
347 use azul_core::selection::ContentIndex;
348
349 use super::*;
350 use crate::text3::cache::{
351 BidiDirection, OverflowInfo, Point, Rect, ShapedGlyphVec, StyleProperties,
352 };
353
354 const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
359 GraphemeClusterId {
360 source_run: run,
361 start_byte_in_run: byte,
362 }
363 }
364
365 const fn ci(run: u32, item: u32) -> ContentIndex {
366 ContentIndex {
367 run_index: run,
368 item_index: item,
369 }
370 }
371
372 fn cluster(text: &str, id: GraphemeClusterId) -> ShapedCluster {
373 ShapedCluster {
374 text: text.to_string(),
375 source_cluster_id: id,
376 source_content_index: ci(id.source_run, id.start_byte_in_run),
377 source_node_id: None,
378 glyphs: ShapedGlyphVec::new(),
379 advance: 10.0,
380 direction: BidiDirection::Ltr,
381 style: Arc::new(StyleProperties::default()),
382 marker_position_outside: None,
383 is_first_fragment: true,
384 is_last_fragment: true,
385 }
386 }
387
388 fn cl(text: &str, id: GraphemeClusterId, line: usize) -> PositionedItem {
390 PositionedItem {
391 item: ShapedItem::Cluster(cluster(text, id)),
392 position: Point::default(),
393 line_index: line,
394 }
395 }
396
397 fn tab(line: usize) -> PositionedItem {
399 PositionedItem {
400 item: ShapedItem::Tab {
401 source: ci(0, 0),
402 bounds: Rect::default(),
403 },
404 position: Point::default(),
405 line_index: line,
406 }
407 }
408
409 fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
410 UnifiedLayout {
411 items,
412 overflow: OverflowInfo::default(),
413 }
414 }
415
416 fn layout_from_str(text: &str, run: u32) -> UnifiedLayout {
419 layout_of(
420 text.char_indices()
421 .map(|(byte_idx, ch)| {
422 let mut buf = [0u8; 4];
423 cl(ch.encode_utf8(&mut buf), gid(run, byte_idx as u32), 0)
424 })
425 .collect(),
426 )
427 }
428
429 const fn cursor_at(id: GraphemeClusterId) -> TextCursor {
430 TextCursor {
431 cluster_id: id,
432 affinity: CursorAffinity::Leading,
433 }
434 }
435
436 const NASTY: &[&str] = &[
440 "",
441 " ",
442 "_",
443 "a",
444 "!",
445 "Hello World",
446 "Hello, World!",
447 " ",
448 "ab ",
449 " ab",
450 "héllo wörld",
451 "日本語のテキスト",
452 "👍👍",
453 "a👍b",
454 "مرحبا بالعالم",
455 "a\u{00A0}b",
456 "a\u{0301}b",
457 "!!!???",
458 "foo_bar42",
459 "\n\t\r ",
460 ];
461
462 #[test]
467 fn word_boundaries_empty_text_at_any_offset_is_zero_zero() {
468 for off in [0, 1, 7, usize::MAX / 2, usize::MAX] {
469 assert_eq!(
470 find_word_boundaries("", off),
471 (0, 0),
472 "empty text must collapse to (0, 0) for offset {off}"
473 );
474 }
475 }
476
477 #[test]
478 fn word_boundaries_usize_max_offset_is_clamped_to_text_len() {
479 let text = "Hello World";
480 let at_max = find_word_boundaries(text, usize::MAX);
481 let at_len = find_word_boundaries(text, text.len());
482
483 assert_eq!(at_max, at_len, "usize::MAX must clamp to text.len()");
484 assert_eq!(&text[at_max.0..at_max.1], "World");
485 }
486
487 #[test]
492 fn word_boundaries_invariants_hold_for_every_offset_of_nasty_unicode() {
493 for &text in NASTY {
494 let probes = (0..=text.len() + 4)
495 .chain([usize::MAX - 1, usize::MAX])
496 .collect::<Vec<_>>();
497
498 for off in probes {
499 let (start, end) = find_word_boundaries(text, off);
500
501 assert!(
502 start <= end,
503 "{text:?} @ {off}: start {start} > end {end} (inverted range)"
504 );
505 assert!(
506 end <= text.len(),
507 "{text:?} @ {off}: end {end} past len {}",
508 text.len()
509 );
510 assert!(
511 text.is_char_boundary(start),
512 "{text:?} @ {off}: start {start} splits a char"
513 );
514 assert!(
515 text.is_char_boundary(end),
516 "{text:?} @ {off}: end {end} splits a char"
517 );
518 let _slice = &text[start..end];
520 }
521 }
522 }
523
524 #[test]
525 fn word_boundaries_offset_inside_multibyte_char_does_not_split_it() {
526 let text = "héllo";
528 let (start, end) = find_word_boundaries(text, 2);
529 assert_eq!(&text[start..end], "héllo");
530
531 let text = "a\u{00A0}b";
533 let (start, end) = find_word_boundaries(text, 2);
534 assert!(text.is_char_boundary(start) && text.is_char_boundary(end));
535 assert_eq!(&text[start..end], "b");
536 }
537
538 #[test]
542 fn word_boundaries_offset_past_trailing_boundary_char_yields_empty_range() {
543 let text = "ab ";
544 assert_eq!(find_word_boundaries(text, 3), (3, 3));
545 assert_eq!(&text[3..3], "");
546 }
547
548 #[test]
553 fn word_boundaries_combining_mark_splits_a_word() {
554 let decomposed = "a\u{0301}b"; let (start, end) = find_word_boundaries(decomposed, 0);
556 assert_eq!(
557 &decomposed[start..end],
558 "a",
559 "combining mark is treated as a word boundary"
560 );
561
562 let precomposed = "áb";
563 let (start, end) = find_word_boundaries(precomposed, 0);
564 assert_eq!(&precomposed[start..end], "áb");
565 }
566
567 #[test]
568 fn word_boundaries_emoji_is_a_boundary_char_cjk_is_a_word_char() {
569 let emoji = "👍👍";
571 assert_eq!(find_word_boundaries(emoji, 0), (0, emoji.len()));
572
573 let cjk = "日本語";
575 let (start, end) = find_word_boundaries(cjk, 3);
576 assert_eq!(&cjk[start..end], "日本語");
577
578 let mixed = "a👍b";
580 let (start, end) = find_word_boundaries(mixed, 0);
581 assert_eq!(&mixed[start..end], "a");
582 }
583
584 #[test]
585 fn word_boundaries_all_boundary_chars_selects_the_whole_run() {
586 let text = "!!!???";
587 assert_eq!(find_word_boundaries(text, 0), (0, 6));
588 assert_eq!(find_word_boundaries(text, 3), (0, 6));
589 assert_eq!(find_word_boundaries(text, 5), (0, 6));
590 }
591
592 #[test]
593 fn word_boundaries_huge_text_with_max_offset_does_not_overflow() {
594 let text = "a".repeat(64 * 1024);
595 let (start, end) = find_word_boundaries(&text, usize::MAX);
596 assert_eq!((start, end), (0, text.len()));
597
598 let sep = " ".repeat(64 * 1024);
600 let (start, end) = find_word_boundaries(&sep, usize::MAX);
601 assert!(start <= end && end <= sep.len());
602 }
603
604 #[test]
609 fn byte_offset_to_cluster_id_empty_map_is_none_for_every_offset() {
610 for off in [0, 1, usize::MAX / 2, usize::MAX] {
611 assert_eq!(byte_offset_to_cluster_id(&[], off), None);
612 }
613 }
614
615 #[test]
618 fn byte_offset_to_cluster_id_non_empty_map_is_always_some() {
619 let map = [(gid(0, 0), 3), (gid(0, 3), 1), (gid(0, 4), 2)];
620 let total: usize = map.iter().map(|(_, l)| l).sum();
621
622 for off in (0..=total + 8).chain([usize::MAX - 1, usize::MAX]) {
623 assert!(
624 byte_offset_to_cluster_id(&map, off).is_some(),
625 "offset {off} returned None for a non-empty map"
626 );
627 }
628 assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(gid(0, 0)));
629 assert_eq!(byte_offset_to_cluster_id(&map, total - 1), Some(gid(0, 4)));
630 assert_eq!(byte_offset_to_cluster_id(&map, usize::MAX), Some(gid(0, 4)));
631 }
632
633 #[test]
636 fn byte_offset_to_cluster_id_zero_length_clusters_are_skipped() {
637 let map = [(gid(0, 0), 0), (gid(0, 1), 2), (gid(0, 3), 0)];
638
639 assert_eq!(
640 byte_offset_to_cluster_id(&map, 0),
641 Some(gid(0, 1)),
642 "leading zero-length cluster must be skipped, not returned"
643 );
644 assert_eq!(byte_offset_to_cluster_id(&map, 1), Some(gid(0, 1)));
645 assert_eq!(byte_offset_to_cluster_id(&map, 2), Some(gid(0, 3)));
647 }
648
649 #[test]
650 fn byte_offset_to_cluster_id_all_zero_length_map_returns_last_never_none() {
651 let map = [(gid(0, 0), 0), (gid(0, 1), 0), (gid(0, 2), 0)];
652 for off in [0, 1, usize::MAX] {
653 assert_eq!(byte_offset_to_cluster_id(&map, off), Some(gid(0, 2)));
654 }
655 }
656
657 #[test]
661 fn byte_offset_to_cluster_id_huge_lengths_do_not_overflow() {
662 let single = [(gid(0, 0), usize::MAX)];
663 assert_eq!(byte_offset_to_cluster_id(&single, 0), Some(gid(0, 0)));
664 assert_eq!(
665 byte_offset_to_cluster_id(&single, usize::MAX - 1),
666 Some(gid(0, 0))
667 );
668 assert_eq!(
670 byte_offset_to_cluster_id(&single, usize::MAX),
671 Some(gid(0, 0))
672 );
673
674 let half = usize::MAX / 2; let pair = [(gid(0, 0), half), (gid(0, 1), half)];
676 assert_eq!(byte_offset_to_cluster_id(&pair, 0), Some(gid(0, 0)));
677 assert_eq!(byte_offset_to_cluster_id(&pair, half - 1), Some(gid(0, 0)));
678 assert_eq!(byte_offset_to_cluster_id(&pair, half), Some(gid(0, 1)));
679 assert_eq!(byte_offset_to_cluster_id(&pair, usize::MAX), Some(gid(0, 1)));
680 }
681
682 #[test]
687 fn find_cluster_at_cursor_empty_layout_is_none() {
688 let layout = layout_of(vec![]);
689 assert!(find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
690 assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
691 }
692
693 #[test]
694 fn find_cluster_at_cursor_unknown_id_is_none() {
695 let layout = layout_from_str("abc", 0);
696 assert!(find_cluster_at_cursor(&cursor_at(gid(0, 99)), &layout).is_none());
698 assert!(find_cluster_at_cursor(&cursor_at(gid(7, 0)), &layout).is_none());
700 assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
702 }
703
704 #[test]
705 fn find_cluster_at_cursor_skips_non_cluster_items_and_reports_visual_index() {
706 let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0), tab(0)]);
707 let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
708 assert_eq!(idx, 1, "index must be into layout.items, skipping the tab");
709 assert_eq!(found.text, "a");
710 }
711
712 #[test]
715 fn find_cluster_at_cursor_duplicate_ids_return_the_first_match() {
716 let layout = layout_of(vec![cl("x", gid(0, 0), 0), cl("y", gid(0, 0), 1)]);
717 let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
718 assert_eq!(idx, 0);
719 assert_eq!(found.text, "x");
720 }
721
722 #[test]
730 #[should_panic(expected = "index out of bounds")]
731 fn extract_line_text_out_of_bounds_index_panics_on_empty_layout() {
732 let layout = layout_of(vec![]);
733 let _ = extract_line_text_and_clusters(0, &layout);
734 }
735
736 #[test]
737 #[should_panic(expected = "index out of bounds")]
738 fn extract_line_text_usize_max_index_panics() {
739 let layout = layout_from_str("abc", 0);
740 let _ = extract_line_text_and_clusters(usize::MAX, &layout);
741 }
742
743 #[test]
744 fn extract_line_text_non_cluster_item_yields_empty_text_and_map() {
745 let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0)]);
746 let (text, map) = extract_line_text_and_clusters(0, &layout);
747 assert!(text.is_empty());
748 assert!(map.is_empty());
749 }
750
751 #[test]
752 fn extract_line_text_zero_index_on_a_cluster_gathers_the_whole_run() {
753 let layout = layout_from_str("hi there", 0);
754 let (text, map) = extract_line_text_and_clusters(0, &layout);
755 assert_eq!(text, "hi there");
756 assert_eq!(map.len(), 8);
757 assert_eq!(map[0], (gid(0, 0), 1));
758 }
759
760 #[test]
764 fn extract_line_text_restores_logical_order_from_reversed_visual_items() {
765 let layout = layout_of(vec![
766 cl("o", gid(0, 4), 0),
767 cl("l", gid(0, 3), 0),
768 cl("l", gid(0, 2), 0),
769 cl("e", gid(0, 1), 0),
770 cl("H", gid(0, 0), 0),
771 ]);
772 let (text, map) = extract_line_text_and_clusters(0, &layout);
773 assert_eq!(text, "Hello", "visual order must not leak into the text");
774 assert_eq!(
775 map.iter().map(|(id, _)| id.start_byte_in_run).collect::<Vec<_>>(),
776 vec![0, 1, 2, 3, 4]
777 );
778 }
779
780 #[test]
783 fn extract_line_text_crosses_visual_lines_and_excludes_other_runs() {
784 let layout = layout_of(vec![
785 cl("H", gid(0, 0), 0),
786 cl("e", gid(0, 1), 0),
787 cl("l", gid(0, 2), 1), cl("X", gid(1, 0), 1), cl("o", gid(0, 3), 2), ]);
791 let (text, map) = extract_line_text_and_clusters(0, &layout);
792 assert_eq!(text, "Helo");
793 assert_eq!(map.len(), 4);
794 assert!(map.iter().all(|(id, _)| id.source_run == 0));
795 }
796
797 #[test]
798 fn extract_line_text_byte_lengths_are_utf8_lengths_not_char_counts() {
799 let layout = layout_from_str("é日👍", 0);
800 let (text, map) = extract_line_text_and_clusters(0, &layout);
801 assert_eq!(text, "é日👍");
802 assert_eq!(
803 map.iter().map(|(_, len)| *len).collect::<Vec<_>>(),
804 vec![2, 3, 4]
805 );
806 assert_eq!(map.iter().map(|(_, l)| l).sum::<usize>(), text.len());
807 }
808
809 #[test]
814 fn select_word_empty_layout_is_none() {
815 let layout = layout_of(vec![]);
816 assert!(select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
817 }
818
819 #[test]
820 fn select_word_unknown_cursor_is_none() {
821 let layout = layout_from_str("Hello", 0);
822 assert!(select_word_at_cursor(&cursor_at(gid(3, 0)), &layout).is_none());
823 assert!(select_word_at_cursor(&cursor_at(gid(0, u32::MAX)), &layout).is_none());
824 }
825
826 #[test]
827 fn select_word_selects_the_word_under_the_cursor_with_correct_affinities() {
828 let layout = layout_from_str("Hello World", 0);
829 let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
830
831 assert_eq!(range.start.cluster_id, gid(0, 0), "start of \"Hello\"");
832 assert_eq!(range.end.cluster_id, gid(0, 4), "last cluster of \"Hello\"");
833 assert_eq!(range.start.affinity, CursorAffinity::Leading);
834 assert_eq!(range.end.affinity, CursorAffinity::Trailing);
835
836 let range = select_word_at_cursor(&cursor_at(gid(0, 8)), &layout).unwrap();
837 assert_eq!(range.start.cluster_id, gid(0, 6));
838 assert_eq!(range.end.cluster_id, gid(0, 10));
839 }
840
841 #[test]
843 fn select_word_spans_a_soft_wrap() {
844 let layout = layout_of(vec![
845 cl("H", gid(0, 0), 0),
846 cl("e", gid(0, 1), 0),
847 cl("l", gid(0, 2), 0),
848 cl("l", gid(0, 3), 1), cl("o", gid(0, 4), 1),
850 ]);
851 let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
852 assert_eq!(range.start.cluster_id, gid(0, 0));
853 assert_eq!(range.end.cluster_id, gid(0, 4), "must cross the line break");
854 }
855
856 #[test]
858 fn select_word_uses_logical_not_visual_order() {
859 let layout = layout_of(vec![
860 cl("o", gid(0, 4), 0),
861 cl("l", gid(0, 3), 0),
862 cl("l", gid(0, 2), 0),
863 cl("e", gid(0, 1), 0),
864 cl("H", gid(0, 0), 0),
865 ]);
866 let range = select_word_at_cursor(&cursor_at(gid(0, 3)), &layout).unwrap();
867 assert_eq!(range.start.cluster_id, gid(0, 0));
868 assert_eq!(range.end.cluster_id, gid(0, 4));
869 }
870
871 #[test]
875 fn select_word_is_idempotent_from_its_own_start_cursor() {
876 for text in ["Hello, World! foo_bar 42", "a b", "héllo wörld", "!!!a"] {
877 let layout = layout_from_str(text, 0);
878
879 for (byte_idx, _) in text.char_indices() {
880 let cur = cursor_at(gid(0, byte_idx as u32));
881 let first = select_word_at_cursor(&cur, &layout)
882 .unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: no selection"));
883 let again = select_word_at_cursor(&first.start, &layout)
884 .unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: re-select failed"));
885
886 assert_eq!(
887 first, again,
888 "{text:?} @ {byte_idx}: selection is not a fixpoint"
889 );
890 }
891 }
892 }
893
894 #[test]
897 fn select_word_invariants_hold_for_every_cursor_of_nasty_unicode() {
898 for &text in NASTY {
899 let layout = layout_from_str(text, 0);
900 let ids: Vec<GraphemeClusterId> = text
901 .char_indices()
902 .map(|(b, _)| gid(0, b as u32))
903 .collect();
904
905 for id in &ids {
906 let range = select_word_at_cursor(&cursor_at(*id), &layout)
907 .unwrap_or_else(|| panic!("{text:?} @ {id:?}: expected a selection"));
908
909 assert!(
910 range.start.cluster_id <= range.end.cluster_id,
911 "{text:?} @ {id:?}: inverted range {range:?}"
912 );
913 assert!(
914 ids.contains(&range.start.cluster_id),
915 "{text:?} @ {id:?}: start is not a cluster of the layout"
916 );
917 assert!(
918 ids.contains(&range.end.cluster_id),
919 "{text:?} @ {id:?}: end is not a cluster of the layout"
920 );
921 assert_eq!(range.start.affinity, CursorAffinity::Leading);
922 assert_eq!(range.end.affinity, CursorAffinity::Trailing);
923 }
924 }
925 }
926
927 #[test]
930 fn select_word_on_zero_length_cluster_resolves_to_a_neighbour() {
931 let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("x", gid(0, 1), 0)]);
932 let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
933 assert_eq!(range.start.cluster_id, gid(0, 1));
934 assert_eq!(range.end.cluster_id, gid(0, 1));
935 }
936
937 #[test]
938 fn select_word_all_clusters_empty_does_not_panic() {
939 let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("", gid(0, 1), 0)]);
940 let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
941 assert_eq!(range.start.cluster_id, gid(0, 1));
943 assert_eq!(range.end.cluster_id, gid(0, 1));
944 }
945
946 #[test]
949 fn select_word_with_inconsistent_cluster_metadata_does_not_panic() {
950 let layout = layout_of(vec![
951 cl("abc", gid(0, 0), 0), cl("def", gid(0, 1), 0),
953 cl("👍", gid(0, 2), 0), ]);
955 for id in [gid(0, 0), gid(0, 1), gid(0, 2)] {
956 let range = select_word_at_cursor(&cursor_at(id), &layout);
957 assert!(range.is_some(), "{id:?} must still resolve");
958 }
959 }
960
961 #[test]
962 fn select_word_large_layout_stays_correct_and_does_not_panic() {
963 let text = "word ".repeat(800);
965 let layout = layout_from_str(&text, 0);
966
967 let word_start = 400 * 5;
969 let range = select_word_at_cursor(&cursor_at(gid(0, word_start as u32 + 2)), &layout)
970 .expect("mid-word cursor must select");
971 assert_eq!(range.start.cluster_id, gid(0, word_start as u32));
972 assert_eq!(range.end.cluster_id, gid(0, word_start as u32 + 3));
973
974 let sep = word_start + 4;
976 let range = select_word_at_cursor(&cursor_at(gid(0, sep as u32)), &layout)
977 .expect("separator cursor must select");
978 assert_eq!(range.start.cluster_id, gid(0, sep as u32));
979 assert_eq!(range.end.cluster_id, gid(0, sep as u32));
980 }
981
982 #[test]
987 fn select_paragraph_empty_layout_is_none() {
988 let layout = layout_of(vec![]);
989 assert!(select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
990 }
991
992 #[test]
993 fn select_paragraph_unknown_cursor_is_none() {
994 let layout = layout_from_str("abc", 0);
995 assert!(select_paragraph_at_cursor(&cursor_at(gid(9, 9)), &layout).is_none());
996 assert!(
997 select_paragraph_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none()
998 );
999 }
1000
1001 #[test]
1002 fn select_paragraph_covers_only_the_cursors_line() {
1003 let layout = layout_of(vec![
1004 cl("a", gid(0, 0), 0),
1005 cl("b", gid(0, 1), 0),
1006 cl("c", gid(0, 2), 1),
1007 cl("d", gid(0, 3), 1),
1008 ]);
1009
1010 let range = select_paragraph_at_cursor(&cursor_at(gid(0, 3)), &layout).unwrap();
1011 assert_eq!(range.start.cluster_id, gid(0, 2), "line 1 starts at 'c'");
1012 assert_eq!(range.end.cluster_id, gid(0, 3));
1013 assert_eq!(range.start.affinity, CursorAffinity::Leading);
1014 assert_eq!(range.end.affinity, CursorAffinity::Trailing);
1015
1016 let range = select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
1017 assert_eq!(range.start.cluster_id, gid(0, 0));
1018 assert_eq!(range.end.cluster_id, gid(0, 1), "must not spill onto line 1");
1019 }
1020
1021 #[test]
1022 fn select_paragraph_ignores_non_cluster_items_at_the_line_edges() {
1023 let layout = layout_of(vec![
1024 tab(0),
1025 cl("a", gid(0, 0), 0),
1026 cl("b", gid(0, 1), 0),
1027 tab(0),
1028 ]);
1029 let range = select_paragraph_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
1030 assert_eq!(range.start.cluster_id, gid(0, 0));
1031 assert_eq!(range.end.cluster_id, gid(0, 1));
1032 }
1033
1034 #[test]
1035 fn select_paragraph_handles_saturated_line_index() {
1036 let layout = layout_of(vec![
1037 cl("a", gid(0, 0), 0),
1038 cl("b", gid(0, 1), usize::MAX),
1039 cl("c", gid(0, 2), usize::MAX),
1040 ]);
1041 let range = select_paragraph_at_cursor(&cursor_at(gid(0, 2)), &layout).unwrap();
1042 assert_eq!(range.start.cluster_id, gid(0, 1));
1043 assert_eq!(range.end.cluster_id, gid(0, 2));
1044 }
1045
1046 #[test]
1052 fn select_paragraph_returns_a_logically_inverted_range_for_reordered_runs() {
1053 let layout = layout_of(vec![
1054 cl("o", gid(0, 4), 0),
1055 cl("l", gid(0, 3), 0),
1056 cl("l", gid(0, 2), 0),
1057 cl("e", gid(0, 1), 0),
1058 cl("H", gid(0, 0), 0),
1059 ]);
1060 let range = select_paragraph_at_cursor(&cursor_at(gid(0, 2)), &layout).unwrap();
1061
1062 assert_eq!(range.start.cluster_id, gid(0, 4), "visually-first cluster");
1063 assert_eq!(range.end.cluster_id, gid(0, 0), "visually-last cluster");
1064 assert!(
1065 range.start.cluster_id > range.end.cluster_id,
1066 "pinned: the range is logically inverted for visual order"
1067 );
1068 }
1069
1070 #[test]
1073 fn select_paragraph_is_some_for_every_reachable_cursor() {
1074 for &text in NASTY {
1075 let layout = layout_from_str(text, 0);
1076 for (byte_idx, _) in text.char_indices() {
1077 let cur = cursor_at(gid(0, byte_idx as u32));
1078 assert!(
1079 select_paragraph_at_cursor(&cur, &layout).is_some(),
1080 "{text:?} @ {byte_idx}: cursor found a cluster but no paragraph"
1081 );
1082 }
1083 }
1084 }
1085}