Skip to main content

azul_layout/text3/
selection.rs

1//! Text selection helper functions
2//!
3//! Provides word and paragraph selection algorithms.
4
5use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
6
7use crate::text3::cache::{
8    is_word_char, PositionedItem, ShapedCluster, ShapedItem, UnifiedLayout,
9};
10
11/// Select the word at the given cursor position
12///
13/// Uses a simple word character heuristic (alphanumeric and underscore)
14/// to determine word start/end. Returns a `SelectionRange` covering the entire word.
15#[must_use] pub fn select_word_at_cursor(
16    cursor: &TextCursor,
17    layout: &UnifiedLayout,
18) -> Option<SelectionRange> {
19    // Find the item containing this cursor
20    let (item_idx, _cluster) = find_cluster_at_cursor(cursor, layout)?;
21
22    // Get text and cluster mapping for this line
23    let (line_text, cluster_map) = extract_line_text_and_clusters(item_idx, layout);
24
25    // Compute byte offset within concatenated line text
26    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    // Find word boundaries in the concatenated text
33    let (word_start, word_end) = find_word_boundaries(&line_text, cursor_byte_offset);
34
35    // Map byte offsets back to cluster IDs
36    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/// Select the paragraph/line at the given cursor position
53///
54/// Returns a `SelectionRange` covering the entire line from the first
55/// to the last cluster on that line.
56#[must_use] pub fn select_paragraph_at_cursor(
57    cursor: &TextCursor,
58    layout: &UnifiedLayout,
59) -> Option<SelectionRange> {
60    // Find the item containing this cursor
61    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    // Find all items on this line
66    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    // Get first and last cluster on line
78    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    // Create selection spanning entire line
88    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
100// Helper Functions
101
102/// Find the cluster containing the given cursor
103fn 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
117/// Extract text and cluster ID mapping for the cursor's logical run.
118///
119/// Returns concatenated text and a vec of (`cluster_id`, `byte_length`) pairs
120/// so byte offsets can be mapped back to cluster IDs.
121///
122/// Clusters are gathered by their logical run (`source_run`) and concatenated in
123/// LOGICAL byte order — NOT visual (`layout.items`) order, and NOT restricted to a
124/// single visual line. This makes word segmentation correct in two cases the old
125/// per-visual-line code broke:
126///   * bidi text, where visual order differs from logical order, so word boundaries
127///     computed on the visual concatenation mapped back to the wrong clusters, and
128///   * a word split across a soft wrap, where filtering to one `line_index` only
129///     selected the fragment on the clicked line.
130fn 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    // Gather every cluster of this logical run across all visual lines, then sort
143    // into logical order so segmentation runs on the real character sequence.
144    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
163/// Map a byte offset in concatenated line text back to a cluster ID.
164fn 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
178/// Find word boundaries around the given byte offset
179///
180/// Uses a simple algorithm: word characters are alphanumeric or underscore,
181/// everything else is a boundary.
182fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
183    // Clamp cursor offset to text length
184    let cursor_offset = cursor_offset.min(text.len());
185
186    // Find word start (scan backwards)
187    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            // Found boundary, word starts after this char
197            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    // Find word end (scan forwards)
207    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            // Found boundary, word ends before this char
215            word_end = *byte_idx;
216            break;
217        }
218    }
219
220    // If cursor is on whitespace, select just that whitespace
221    if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
222        if !is_word_char(*ch) {
223            // Find span of consecutive whitespace/punctuation
224            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// Word-character classification is shared with cursor word-motion via
243// `cache::is_word_char` (imported above) so selection and Ctrl/Alt+Arrow agree
244// on punctuation. Kept distinct from `cache::is_word_separator`, which is for
245// word-spacing justification, not segmentation.
246
247#[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/// Adversarial unit tests generated for `layout/src/text3/selection.rs`.
328///
329/// These push the selection helpers at the boundaries the production callers never
330/// reach: `usize::MAX` byte offsets, offsets that land *inside* a multi-byte char,
331/// zero-length clusters, empty layouts, cluster ids that do not exist, visually
332/// reordered (bidi) item vectors, words split across a soft wrap, and cluster
333/// metadata that contradicts the cluster text. Where the current behaviour is
334/// surprising but real (an empty range for a trailing boundary char; a combining
335/// mark splitting a word; paragraph selection returning a *logically inverted*
336/// range for reordered runs) the test PINS that behaviour and says so rather than
337/// pretending it is correct.
338#[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    // ------------------------------------------------------------------
355    // Fixtures
356    // ------------------------------------------------------------------
357
358    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    /// A cluster item on `line`.
389    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    /// A non-cluster item (`as_cluster()` returns `None`) on `line`.
398    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    /// One cluster per `char` of `text`, all in `run`, all on line 0, with
417    /// `start_byte_in_run` equal to the real logical byte offset of the char.
418    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    /// Strings chosen to break byte/char assumptions: ASCII, CJK (alphanumeric,
437    /// 3 bytes), emoji (NOT alphanumeric, 4 bytes), Arabic (RTL), NBSP (a 2-byte
438    /// *non*-word char), a combining mark, and pathological all-boundary input.
439    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    // ------------------------------------------------------------------
463    // find_word_boundaries — numeric: zero / min_max / overflow / unicode
464    // ------------------------------------------------------------------
465
466    #[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    /// The load-bearing safety invariant: whatever offset it is handed — including
488    /// offsets *inside* a multi-byte char and offsets past the end — the returned
489    /// pair must be sliceable, ordered, and on char boundaries. Callers slice
490    /// `&text[start..end]`, so a violation here is an immediate panic in the caller.
491    #[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                // Must not panic — this is what every caller does with the result.
519                let _slice = &text[start..end];
520            }
521        }
522    }
523
524    #[test]
525    fn word_boundaries_offset_inside_multibyte_char_does_not_split_it() {
526        // 'é' occupies bytes 1..3; offset 2 is *inside* it.
527        let text = "héllo";
528        let (start, end) = find_word_boundaries(text, 2);
529        assert_eq!(&text[start..end], "héllo");
530
531        // NBSP occupies bytes 1..3 and is NOT a word char; offset 2 is inside it.
532        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    /// PINNED QUIRK: an offset that sits *after* a trailing boundary char (only
539    /// reachable by a direct call, not through a cluster id) yields the empty
540    /// range `(len, len)` rather than selecting the trailing whitespace.
541    #[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    /// PINNED QUIRK: `is_word_char` is `is_alphanumeric() || '_'`, and a combining
549    /// mark (category Mn) is neither — so decomposed "á" is segmented as TWO words.
550    /// NFC "á" (a single precomposed alphanumeric char) is not. Real Unicode
551    /// weakness of the heuristic; recorded, not worked around.
552    #[test]
553    fn word_boundaries_combining_mark_splits_a_word() {
554        let decomposed = "a\u{0301}b"; // a + COMBINING ACUTE + b
555        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        // Emoji are not alphanumeric → boundary run selected whole.
570        let emoji = "👍👍";
571        assert_eq!(find_word_boundaries(emoji, 0), (0, emoji.len()));
572
573        // Ideographs ARE alphanumeric → one word.
574        let cjk = "日本語";
575        let (start, end) = find_word_boundaries(cjk, 3);
576        assert_eq!(&cjk[start..end], "日本語");
577
578        // Emoji between words acts as a separator.
579        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        // …and the same for a huge all-boundary text.
599        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    // ------------------------------------------------------------------
605    // byte_offset_to_cluster_id — numeric: zero / min_max / overflow
606    // ------------------------------------------------------------------
607
608    #[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    /// Non-empty map ⇒ ALWAYS `Some` (offsets past the end fall back to the last
616    /// cluster). Exhaustive over every offset in and beyond the mapped range.
617    #[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    /// PINNED QUIRK: a zero-length cluster is *unaddressable* — `offset < cum + 0`
634    /// is never true — so it is silently skipped and the next cluster wins.
635    #[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        // Past the end → last entry, even though the last entry is zero-length.
646        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    /// Cluster lengths at the top of the `usize` range: a single `usize::MAX`-long
658    /// cluster, and two `usize::MAX / 2`-long ones whose running sum stays just
659    /// below the overflow point. The internal `cumulative + len` must not wrap.
660    #[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        // offset == len → falls through the loop, then to the `last()` fallback.
669        assert_eq!(
670            byte_offset_to_cluster_id(&single, usize::MAX),
671            Some(gid(0, 0))
672        );
673
674        let half = usize::MAX / 2; // 2*half == usize::MAX - 1, no wrap.
675        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    // ------------------------------------------------------------------
683    // find_cluster_at_cursor — getters / predicates: invariants
684    // ------------------------------------------------------------------
685
686    #[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        // Right run, byte offset past the end.
697        assert!(find_cluster_at_cursor(&cursor_at(gid(0, 99)), &layout).is_none());
698        // Right byte offset, wrong run.
699        assert!(find_cluster_at_cursor(&cursor_at(gid(7, 0)), &layout).is_none());
700        // Saturated id.
701        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    /// Duplicate cluster ids are not supposed to happen; if they do, the FIRST
713    /// visual match wins. Pinned so a change of iteration order is caught.
714    #[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    // ------------------------------------------------------------------
723    // extract_line_text_and_clusters — numeric: index bounds / ordering
724    // ------------------------------------------------------------------
725
726    /// `layout.items[item_idx]` is an unchecked index: the function's contract is
727    /// that `item_idx` comes from `find_cluster_at_cursor`. Pin the panic so that
728    /// contract is not silently broadened.
729    #[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    /// Documented behaviour: clusters are gathered by LOGICAL run and sorted by
761    /// `start_byte_in_run`, so a visually reordered (bidi) item vector must still
762    /// concatenate in logical order.
763    #[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    /// Documented behaviour: gathering is NOT restricted to one visual line, so a
781    /// word split by a soft wrap is still reassembled.
782    #[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), // soft-wrapped onto line 1
788            cl("X", gid(1, 0), 1), // a different logical run — must be excluded
789            cl("o", gid(0, 3), 2), // and onto line 2
790        ]);
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    // ------------------------------------------------------------------
810    // select_word_at_cursor — round-trip + invariants
811    // ------------------------------------------------------------------
812
813    #[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    /// A word broken by a soft wrap must select whole, not just the clicked fragment.
842    #[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), // wrapped
849            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    /// Bidi: items in visual (reversed) order must still yield the logical word.
857    #[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    /// Round-trip / idempotence: re-selecting from the START of a returned range
872    /// must reproduce the identical range. A fixpoint failure here would make
873    /// double-click-then-drag jitter.
874    #[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    /// Invariants over every reachable cursor of every nasty string: always `Some`,
895    /// never inverted, both endpoints are real clusters of the layout, affinities fixed.
896    #[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    /// A zero-length cluster (empty `text`) cannot be addressed by a byte offset,
928    /// so selecting *on* it resolves to the neighbouring cluster instead of panicking.
929    #[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        // Empty concatenated text ⇒ boundaries (0, 0) ⇒ both ends fall back to last().
942        assert_eq!(range.start.cluster_id, gid(0, 1));
943        assert_eq!(range.end.cluster_id, gid(0, 1));
944    }
945
946    /// Cluster metadata that contradicts the cluster text (`start_byte_in_run`
947    /// values that do not match the utf-8 lengths) must not panic or slice mid-char.
948    #[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), // claims 1 byte, is 3
952            cl("def", gid(0, 1), 0),
953            cl("👍", gid(0, 2), 0), // multi-byte at a bogus offset
954        ]);
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        // 4000 clusters: 500 × "word " (word chars + a separator).
964        let text = "word ".repeat(800);
965        let layout = layout_from_str(&text, 0);
966
967        // Cursor in the middle of the 400th word.
968        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        // Cursor on the separator selects just the separator.
975        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    // ------------------------------------------------------------------
983    // select_paragraph_at_cursor — invariants
984    // ------------------------------------------------------------------
985
986    #[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    /// PINNED QUIRK: paragraph selection walks `layout.items` in VISUAL order,
1047    /// unlike `select_word_at_cursor`, which sorts into logical order. For a
1048    /// visually reordered (RTL) run the returned range is therefore logically
1049    /// INVERTED (start > end). Recorded as-is — a caller that assumes
1050    /// `start <= end` will mis-highlight RTL lines.
1051    #[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    /// Whenever the cursor resolves to a cluster, paragraph selection must resolve
1071    /// too (its line always contains at least that cluster) — never `None`.
1072    #[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}