1
//! Text selection helper functions
2
//!
3
//! Provides word and paragraph selection algorithms.
4

            
5
use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
6

            
7
use crate::text3::cache::{
8
    is_word_char, BreakType, 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
216
#[must_use] pub fn select_word_at_cursor(
16
216
    cursor: &TextCursor,
17
216
    layout: &UnifiedLayout,
18
216
) -> Option<SelectionRange> {
19
    // Find the item containing this cursor
20
216
    let (item_idx, _cluster) = find_cluster_at_cursor(cursor, layout)?;
21

            
22
    // Get text and cluster mapping for this line
23
213
    let (line_text, cluster_map) = extract_line_text_and_clusters(item_idx, layout);
24

            
25
    // Compute byte offset within concatenated line text
26
213
    let cursor_byte_offset = cluster_map
27
213
        .iter()
28
5275
        .take_while(|(id, _)| *id != cursor.cluster_id)
29
213
        .map(|(_, len)| len)
30
213
        .sum::<usize>();
31

            
32
    // Find word boundaries in the concatenated text
33
213
    let (word_start, word_end) = find_word_boundaries(&line_text, cursor_byte_offset);
34

            
35
    // Map byte offsets back to cluster IDs
36
213
    let start_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_start)?;
37
213
    let end_cluster_id = byte_offset_to_cluster_id(&cluster_map, word_end.saturating_sub(1))
38
213
        .unwrap_or(start_cluster_id);
39

            
40
213
    Some(SelectionRange {
41
213
        start: TextCursor {
42
213
            cluster_id: start_cluster_id,
43
213
            affinity: CursorAffinity::Leading,
44
213
        },
45
213
        end: TextCursor {
46
213
            cluster_id: end_cluster_id,
47
213
            affinity: CursorAffinity::Trailing,
48
213
        },
49
213
    })
50
216
}
51

            
52
/// Select the paragraph at the given cursor position (triple-click).
53
///
54
/// Returns a `SelectionRange` covering the whole paragraph the cursor is in,
55
/// in LOGICAL order, from its first to its last grapheme cluster.
56
///
57
/// "Paragraph" is the run of inline content between HARD breaks (`<br>`, a
58
/// preserved newline) — soft wraps do not divide it. Two consequences, both of
59
/// which the old `line_index` filter got wrong:
60
///   * a wrapped paragraph selects whole instead of one visual line (no editor
61
///     selects a soft-wrapped fragment on triple-click), and
62
///   * a visually reordered (bidi/RTL) paragraph yields `start <= end`, because
63
///     the endpoints are taken in logical order rather than from the ends of
64
///     the visual item vector — an inverted range mis-highlights, as
65
///     `get_selection_rects` walks logically.
66
///
67
/// Same gathering discipline as [`extract_line_text_and_clusters`], which fixed
68
/// this class for WORD selection: work on the logical sequence, not on one
69
/// visual line.
70
224
#[must_use] pub fn select_paragraph_at_cursor(
71
224
    cursor: &TextCursor,
72
224
    layout: &UnifiedLayout,
73
224
) -> Option<SelectionRange> {
74
    // Find the item containing this cursor
75
224
    let (_, cursor_cluster) = find_cluster_at_cursor(cursor, layout)?;
76
221
    let cursor_run = cursor_cluster.source_cluster_id.source_run;
77

            
78
    // Hard breaks occupy their OWN slot in the inline-content array, and every
79
    // cluster carries that array index as `source_run` — so a break's run index
80
    // is a boundary in the same coordinate the clusters are ordered by. Take
81
    // the nearest one on each side of the cursor's run; either may be absent
82
    // (the paragraph reaches the start/end of the IFC).
83
221
    let mut before: Option<u32> = None;
84
221
    let mut after: Option<u32> = None;
85
1983
    for item in &layout.items {
86
1762
        let ShapedItem::Break { source, break_info } = &item.item else {
87
1755
            continue;
88
        };
89
7
        if break_info.break_type != BreakType::Hard {
90
1
            continue;
91
6
        }
92
6
        let run = source.run_index;
93
6
        match run.cmp(&cursor_run) {
94
            core::cmp::Ordering::Less => {
95
3
                before = Some(before.map_or(run, |b: u32| b.max(run)));
96
            }
97
            core::cmp::Ordering::Greater => {
98
3
                after = Some(after.map_or(run, |a: u32| a.min(run)));
99
            }
100
            core::cmp::Ordering::Equal => {}
101
        }
102
    }
103

            
104
    // Logical extremes of every cluster inside those bounds. `GraphemeClusterId`
105
    // orders by (source_run, start_byte_in_run), which IS logical order.
106
221
    let mut first: Option<GraphemeClusterId> = None;
107
221
    let mut last: Option<GraphemeClusterId> = None;
108
1762
    for cluster in layout.items.iter().filter_map(|item| item.item.as_cluster()) {
109
1753
        let id = cluster.source_cluster_id;
110
1753
        if before.is_some_and(|b| id.source_run <= b) || after.is_some_and(|a| id.source_run >= a) {
111
10
            continue;
112
1743
        }
113
1743
        if first.is_none_or(|f| id < f) {
114
225
            first = Some(id);
115
1518
        }
116
1743
        if last.is_none_or(|l| id > l) {
117
1739
            last = Some(id);
118
1739
        }
119
    }
120

            
121
    Some(SelectionRange {
122
        start: TextCursor {
123
221
            cluster_id: first?,
124
221
            affinity: CursorAffinity::Leading,
125
        },
126
        end: TextCursor {
127
221
            cluster_id: last?,
128
221
            affinity: CursorAffinity::Trailing,
129
        },
130
    })
131
224
}
132

            
133
// Helper Functions
134

            
135
/// Find the cluster containing the given cursor
136
447
fn find_cluster_at_cursor<'a>(
137
447
    cursor: &TextCursor,
138
447
    layout: &'a UnifiedLayout,
139
447
) -> Option<(usize, &'a ShapedCluster)> {
140
6294
    layout.items.iter().enumerate().find_map(|(idx, item)| {
141
6294
        if let ShapedItem::Cluster(cluster) = &item.item {
142
6289
            if cluster.source_cluster_id == cursor.cluster_id {
143
436
                return Some((idx, cluster));
144
5853
            }
145
5
        }
146
5858
        None
147
6294
    })
148
447
}
149

            
150
/// Extract text and cluster ID mapping for the cursor's logical run.
151
///
152
/// Returns concatenated text and a vec of (`cluster_id`, `byte_length`) pairs
153
/// so byte offsets can be mapped back to cluster IDs.
154
///
155
/// Clusters are gathered by their logical run (`source_run`) and concatenated in
156
/// LOGICAL byte order — NOT visual (`layout.items`) order, and NOT restricted to a
157
/// single visual line. This makes word segmentation correct in two cases the old
158
/// per-visual-line code broke:
159
///   * bidi text, where visual order differs from logical order, so word boundaries
160
///     computed on the visual concatenation mapped back to the wrong clusters, and
161
///   * a word split across a soft wrap, where filtering to one `line_index` only
162
///     selected the fragment on the clicked line.
163
220
fn extract_line_text_and_clusters(
164
220
    item_idx: usize,
165
220
    layout: &UnifiedLayout,
166
220
) -> (String, Vec<(GraphemeClusterId, usize)>) {
167
220
    let Some(source_run) = layout.items[item_idx]
168
220
        .item
169
220
        .as_cluster()
170
220
        .map(|c| c.source_cluster_id.source_run)
171
    else {
172
1
        return (String::new(), Vec::new());
173
    };
174

            
175
    // Gather every cluster of this logical run across all visual lines, then sort
176
    // into logical order so segmentation runs on the real character sequence.
177
219
    let mut clusters: Vec<&ShapedCluster> = layout
178
219
        .items
179
219
        .iter()
180
10529
        .filter_map(|item| item.item.as_cluster())
181
10529
        .filter(|c| c.source_cluster_id.source_run == source_run)
182
219
        .collect();
183
219
    clusters.sort_by_key(|c| c.source_cluster_id.start_byte_in_run);
184

            
185
219
    let mut text = String::new();
186
219
    let mut cluster_map = Vec::new();
187
10745
    for c in clusters {
188
10526
        let s = c.text();
189
10526
        cluster_map.push((c.source_cluster_id, s.len()));
190
10526
        text.push_str(s);
191
10526
    }
192

            
193
219
    (text, cluster_map)
194
220
}
195

            
196
/// Map a byte offset in concatenated line text back to a cluster ID.
197
468
fn byte_offset_to_cluster_id(
198
468
    cluster_map: &[(GraphemeClusterId, usize)],
199
468
    byte_offset: usize,
200
468
) -> Option<GraphemeClusterId> {
201
468
    let mut cumulative = 0;
202
10820
    for (id, len) in cluster_map {
203
10795
        if byte_offset < cumulative + len {
204
443
            return Some(*id);
205
10352
        }
206
10352
        cumulative += len;
207
    }
208
25
    cluster_map.last().map(|(id, _)| *id)
209
468
}
210

            
211
/// Find word boundaries around the given byte offset
212
///
213
/// Uses a simple algorithm: word characters are alphanumeric or underscore,
214
/// everything else is a boundary.
215
522
fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
216
    // Clamp cursor offset to text length
217
522
    let cursor_offset = cursor_offset.min(text.len());
218

            
219
    // Find word start (scan backwards)
220
522
    let mut word_start = 0;
221
522
    let char_indices: Vec<(usize, char)> = text.char_indices().collect();
222

            
223
72922
    for (i, (byte_idx, ch)) in char_indices.iter().enumerate().rev() {
224
72922
        if *byte_idx >= cursor_offset {
225
6142
            continue;
226
66780
        }
227

            
228
66780
        if !is_word_char(*ch) {
229
            // Found boundary, word starts after this char
230
275
            word_start = if i + 1 < char_indices.len() {
231
214
                char_indices[i + 1].0
232
            } else {
233
61
                text.len()
234
            };
235
275
            break;
236
66505
        }
237
    }
238

            
239
    // Find word end (scan forwards)
240
522
    let mut word_end = text.len();
241
139063
    for (byte_idx, ch) in &char_indices {
242
138728
        if *byte_idx <= cursor_offset {
243
137783
            continue;
244
945
        }
245

            
246
945
        if !is_word_char(*ch) {
247
            // Found boundary, word ends before this char
248
187
            word_end = *byte_idx;
249
187
            break;
250
758
        }
251
    }
252

            
253
    // If cursor is on whitespace, select just that whitespace
254
137934
    if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
255
326
        if !is_word_char(*ch) {
256
            // Find span of consecutive whitespace/punctuation
257
84
            let start = char_indices
258
84
                .iter()
259
84
                .rev()
260
2467
                .find(|(idx, c)| *idx < cursor_offset && is_word_char(*c))
261
84
                .map_or(0, |(idx, c)| idx + c.len_utf8());
262

            
263
84
            let end = char_indices
264
84
                .iter()
265
2492
                .find(|(idx, c)| *idx > cursor_offset && is_word_char(*c))
266
84
                .map_or(text.len(), |(idx, _)| *idx);
267

            
268
84
            return (start, end);
269
242
        }
270
196
    }
271

            
272
438
    (word_start, word_end)
273
522
}
274

            
275
// Word-character classification is shared with cursor word-motion via
276
// `cache::is_word_char` (imported above) so selection and Ctrl/Alt+Arrow agree
277
// on punctuation. Kept distinct from `cache::is_word_separator`, which is for
278
// word-spacing justification, not segmentation.
279

            
280
#[cfg(test)]
281
mod tests {
282
    use super::*;
283

            
284
    #[test]
285
1
    fn test_word_boundaries_simple() {
286
1
        let text = "Hello World";
287
1
        let (start, end) = find_word_boundaries(text, 2);
288
1
        assert_eq!(&text[start..end], "Hello");
289

            
290
1
        let (start, end) = find_word_boundaries(text, 7);
291
1
        assert_eq!(&text[start..end], "World");
292

            
293
1
        let (start, end) = find_word_boundaries(text, 5);
294
1
        assert_eq!(&text[start..end], " ");
295
1
    }
296

            
297
    #[test]
298
1
    fn test_word_boundaries_start_end() {
299
1
        let text = "Hello";
300
1
        let (start, end) = find_word_boundaries(text, 0);
301
1
        assert_eq!(&text[start..end], "Hello");
302

            
303
1
        let (start, end) = find_word_boundaries(text, 5);
304
1
        assert_eq!(&text[start..end], "Hello");
305
1
    }
306

            
307
    #[test]
308
1
    fn test_word_boundaries_punctuation() {
309
1
        let text = "Hello, World!";
310
1
        let (start, end) = find_word_boundaries(text, 2);
311
1
        assert_eq!(&text[start..end], "Hello");
312

            
313
1
        let (start, end) = find_word_boundaries(text, 5);
314
1
        assert_eq!(&text[start..end], ", ");
315

            
316
1
        let (start, end) = find_word_boundaries(text, 8);
317
1
        assert_eq!(&text[start..end], "World");
318
1
    }
319

            
320
    #[test]
321
1
    fn test_word_boundaries_underscore() {
322
1
        let text = "hello_world";
323
1
        let (start, end) = find_word_boundaries(text, 5);
324
1
        assert_eq!(&text[start..end], "hello_world");
325
1
    }
326

            
327
    #[test]
328
1
    fn test_is_word_char() {
329
1
        assert!(is_word_char('a'));
330
1
        assert!(is_word_char('Z'));
331
1
        assert!(is_word_char('0'));
332
1
        assert!(is_word_char('_'));
333
1
        assert!(!is_word_char(' '));
334
1
        assert!(!is_word_char(','));
335
1
        assert!(!is_word_char('!'));
336
1
    }
337

            
338
    #[test]
339
1
    fn test_word_boundaries_empty() {
340
1
        let (start, end) = find_word_boundaries("", 0);
341
1
        assert_eq!(start, 0);
342
1
        assert_eq!(end, 0);
343
1
    }
344

            
345
    #[test]
346
1
    fn test_byte_offset_to_cluster_id_basic() {
347
1
        let id0 = GraphemeClusterId { source_run: 0, start_byte_in_run: 0 };
348
1
        let id1 = GraphemeClusterId { source_run: 0, start_byte_in_run: 5 };
349
1
        let id2 = GraphemeClusterId { source_run: 0, start_byte_in_run: 6 };
350
1
        let map = vec![(id0, 5), (id1, 1), (id2, 5)];
351

            
352
1
        assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(id0));
353
1
        assert_eq!(byte_offset_to_cluster_id(&map, 4), Some(id0));
354
1
        assert_eq!(byte_offset_to_cluster_id(&map, 5), Some(id1));
355
1
        assert_eq!(byte_offset_to_cluster_id(&map, 6), Some(id2));
356
1
        assert_eq!(byte_offset_to_cluster_id(&map, 100), Some(id2));
357
1
    }
358
}
359

            
360
/// Adversarial unit tests generated for `layout/src/text3/selection.rs`.
361
///
362
/// These push the selection helpers at the boundaries the production callers never
363
/// reach: `usize::MAX` byte offsets, offsets that land *inside* a multi-byte char,
364
/// zero-length clusters, empty layouts, cluster ids that do not exist, visually
365
/// reordered (bidi) item vectors, words split across a soft wrap, and cluster
366
/// metadata that contradicts the cluster text. Where the current behaviour is
367
/// surprising but real (an empty range for a trailing boundary char; a combining
368
/// mark splitting a word) the test PINS that behaviour and says so rather than
369
/// pretending it is correct.
370
#[cfg(test)]
371
#[allow(
372
    clippy::cast_possible_truncation,
373
    clippy::similar_names,
374
    clippy::too_many_lines
375
)]
376
mod autotest_generated {
377
    use std::sync::Arc;
378

            
379
    use azul_core::selection::ContentIndex;
380

            
381
    use super::*;
382
    use crate::text3::cache::{
383
        BidiDirection, ClearType, InlineBreak, OverflowInfo, Point, PositionedItem, Rect,
384
        ShapedGlyphVec, StyleProperties,
385
    };
386

            
387
    // ------------------------------------------------------------------
388
    // Fixtures
389
    // ------------------------------------------------------------------
390

            
391
    const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
392
        GraphemeClusterId {
393
            source_run: run,
394
            start_byte_in_run: byte,
395
        }
396
    }
397

            
398
    const fn ci(run: u32, item: u32) -> ContentIndex {
399
        ContentIndex {
400
            run_index: run,
401
            item_index: item,
402
        }
403
    }
404

            
405
    fn cluster(text: &str, id: GraphemeClusterId) -> ShapedCluster {
406
        ShapedCluster {
407
            flags: crate::text3::cache::ClusterFlags::classify(text),
408
            source_text: {
409
                // Test helper: pad so the slice at the given id's offset
410
                // yields exactly `text` (production stamps a shared Arc
411
                // whose offsets are real; tests mint ids freely).
412
                let mut s = String::new();
413
                for _ in 0..id.start_byte_in_run { s.push(' '); }
414
                s.push_str(text);
415
                Arc::from(s.as_str())
416
            },
417
            source_byte_len: text.len() as u16,
418
            source_cluster_id: id,
419
            source_content_index: ci(id.source_run, id.start_byte_in_run),
420
            source_node_id: None,
421
            glyphs: ShapedGlyphVec::new(),
422
            advance: 10.0,
423
            direction: BidiDirection::Ltr,
424
            style: Arc::new(StyleProperties::default()),
425
            marker_position_outside: None,
426
            is_first_fragment: true,
427
            is_last_fragment: true,
428
        }
429
    }
430

            
431
    /// A cluster item on `line`.
432
    fn cl(text: &str, id: GraphemeClusterId, line: usize) -> PositionedItem {
433
        PositionedItem {
434
            item: ShapedItem::Cluster(cluster(text, id)),
435
            position: Point::default(),
436
            line_index: line,
437
        }
438
    }
439

            
440
    /// A forced break occupying inline-content slot `run` (its own slot, like
441
    /// production: `<br>` / a preserved newline is an `InlineContent::LineBreak`
442
    /// between the text runs it separates).
443
    fn hard_break(run: u32, line: usize) -> PositionedItem {
444
        break_item(run, line, BreakType::Hard)
445
    }
446

            
447
    /// A soft wrap opportunity — NOT a paragraph boundary.
448
    fn soft_break(run: u32, line: usize) -> PositionedItem {
449
        break_item(run, line, BreakType::Soft)
450
    }
451

            
452
    fn break_item(run: u32, line: usize, break_type: BreakType) -> PositionedItem {
453
        PositionedItem {
454
            item: ShapedItem::Break {
455
                source: ci(run, 0),
456
                break_info: InlineBreak {
457
                    break_type,
458
                    clear: ClearType::None,
459
                    content_index: 0,
460
                },
461
            },
462
            position: Point::default(),
463
            line_index: line,
464
        }
465
    }
466

            
467
    /// A non-cluster item (`as_cluster()` returns `None`) on `line`.
468
    fn tab(line: usize) -> PositionedItem {
469
        PositionedItem {
470
            item: ShapedItem::Tab {
471
                source: ci(0, 0),
472
                bounds: Rect::default(),
473
            },
474
            position: Point::default(),
475
            line_index: line,
476
        }
477
    }
478

            
479
    fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
480
        UnifiedLayout {
481
            items,
482
            overflow: OverflowInfo::default(),
483
        }
484
    }
485

            
486
    /// One cluster per `char` of `text`, all in `run`, all on line 0, with
487
    /// `start_byte_in_run` equal to the real logical byte offset of the char.
488
    fn layout_from_str(text: &str, run: u32) -> UnifiedLayout {
489
        layout_of(
490
            text.char_indices()
491
                .map(|(byte_idx, ch)| {
492
                    let mut buf = [0u8; 4];
493
                    cl(ch.encode_utf8(&mut buf), gid(run, byte_idx as u32), 0)
494
                })
495
                .collect(),
496
        )
497
    }
498

            
499
    const fn cursor_at(id: GraphemeClusterId) -> TextCursor {
500
        TextCursor {
501
            cluster_id: id,
502
            affinity: CursorAffinity::Leading,
503
        }
504
    }
505

            
506
    /// Strings chosen to break byte/char assumptions: ASCII, CJK (alphanumeric,
507
    /// 3 bytes), emoji (NOT alphanumeric, 4 bytes), Arabic (RTL), NBSP (a 2-byte
508
    /// *non*-word char), a combining mark, and pathological all-boundary input.
509
    const NASTY: &[&str] = &[
510
        "",
511
        " ",
512
        "_",
513
        "a",
514
        "!",
515
        "Hello World",
516
        "Hello, World!",
517
        "  ",
518
        "ab ",
519
        " ab",
520
        "héllo wörld",
521
        "日本語のテキスト",
522
        "👍👍",
523
        "a👍b",
524
        "مرحبا بالعالم",
525
        "a\u{00A0}b",
526
        "a\u{0301}b",
527
        "!!!???",
528
        "foo_bar42",
529
        "\n\t\r ",
530
    ];
531

            
532
    // ------------------------------------------------------------------
533
    // find_word_boundaries — numeric: zero / min_max / overflow / unicode
534
    // ------------------------------------------------------------------
535

            
536
    #[test]
537
    fn word_boundaries_empty_text_at_any_offset_is_zero_zero() {
538
        for off in [0, 1, 7, usize::MAX / 2, usize::MAX] {
539
            assert_eq!(
540
                find_word_boundaries("", off),
541
                (0, 0),
542
                "empty text must collapse to (0, 0) for offset {off}"
543
            );
544
        }
545
    }
546

            
547
    #[test]
548
    fn word_boundaries_usize_max_offset_is_clamped_to_text_len() {
549
        let text = "Hello World";
550
        let at_max = find_word_boundaries(text, usize::MAX);
551
        let at_len = find_word_boundaries(text, text.len());
552

            
553
        assert_eq!(at_max, at_len, "usize::MAX must clamp to text.len()");
554
        assert_eq!(&text[at_max.0..at_max.1], "World");
555
    }
556

            
557
    /// The load-bearing safety invariant: whatever offset it is handed — including
558
    /// offsets *inside* a multi-byte char and offsets past the end — the returned
559
    /// pair must be sliceable, ordered, and on char boundaries. Callers slice
560
    /// `&text[start..end]`, so a violation here is an immediate panic in the caller.
561
    #[test]
562
    fn word_boundaries_invariants_hold_for_every_offset_of_nasty_unicode() {
563
        for &text in NASTY {
564
            let probes = (0..=text.len() + 4)
565
                .chain([usize::MAX - 1, usize::MAX])
566
                .collect::<Vec<_>>();
567

            
568
            for off in probes {
569
                let (start, end) = find_word_boundaries(text, off);
570

            
571
                assert!(
572
                    start <= end,
573
                    "{text:?} @ {off}: start {start} > end {end} (inverted range)"
574
                );
575
                assert!(
576
                    end <= text.len(),
577
                    "{text:?} @ {off}: end {end} past len {}",
578
                    text.len()
579
                );
580
                assert!(
581
                    text.is_char_boundary(start),
582
                    "{text:?} @ {off}: start {start} splits a char"
583
                );
584
                assert!(
585
                    text.is_char_boundary(end),
586
                    "{text:?} @ {off}: end {end} splits a char"
587
                );
588
                // Must not panic — this is what every caller does with the result.
589
                let _slice = &text[start..end];
590
            }
591
        }
592
    }
593

            
594
    #[test]
595
    fn word_boundaries_offset_inside_multibyte_char_does_not_split_it() {
596
        // 'é' occupies bytes 1..3; offset 2 is *inside* it.
597
        let text = "héllo";
598
        let (start, end) = find_word_boundaries(text, 2);
599
        assert_eq!(&text[start..end], "héllo");
600

            
601
        // NBSP occupies bytes 1..3 and is NOT a word char; offset 2 is inside it.
602
        let text = "a\u{00A0}b";
603
        let (start, end) = find_word_boundaries(text, 2);
604
        assert!(text.is_char_boundary(start) && text.is_char_boundary(end));
605
        assert_eq!(&text[start..end], "b");
606
    }
607

            
608
    /// PINNED QUIRK: an offset that sits *after* a trailing boundary char (only
609
    /// reachable by a direct call, not through a cluster id) yields the empty
610
    /// range `(len, len)` rather than selecting the trailing whitespace.
611
    #[test]
612
    fn word_boundaries_offset_past_trailing_boundary_char_yields_empty_range() {
613
        let text = "ab ";
614
        assert_eq!(find_word_boundaries(text, 3), (3, 3));
615
        assert_eq!(&text[3..3], "");
616
    }
617

            
618
    /// PINNED QUIRK: `is_word_char` is `is_alphanumeric() || '_'`, and a combining
619
    /// mark (category Mn) is neither — so decomposed "á" is segmented as TWO words.
620
    /// NFC "á" (a single precomposed alphanumeric char) is not. Real Unicode
621
    /// weakness of the heuristic; recorded, not worked around.
622
    #[test]
623
    fn word_boundaries_combining_mark_splits_a_word() {
624
        let decomposed = "a\u{0301}b"; // a + COMBINING ACUTE + b
625
        let (start, end) = find_word_boundaries(decomposed, 0);
626
        assert_eq!(
627
            &decomposed[start..end],
628
            "a",
629
            "combining mark is treated as a word boundary"
630
        );
631

            
632
        let precomposed = "áb";
633
        let (start, end) = find_word_boundaries(precomposed, 0);
634
        assert_eq!(&precomposed[start..end], "áb");
635
    }
636

            
637
    #[test]
638
    fn word_boundaries_emoji_is_a_boundary_char_cjk_is_a_word_char() {
639
        // Emoji are not alphanumeric → boundary run selected whole.
640
        let emoji = "👍👍";
641
        assert_eq!(find_word_boundaries(emoji, 0), (0, emoji.len()));
642

            
643
        // Ideographs ARE alphanumeric → one word.
644
        let cjk = "日本語";
645
        let (start, end) = find_word_boundaries(cjk, 3);
646
        assert_eq!(&cjk[start..end], "日本語");
647

            
648
        // Emoji between words acts as a separator.
649
        let mixed = "a👍b";
650
        let (start, end) = find_word_boundaries(mixed, 0);
651
        assert_eq!(&mixed[start..end], "a");
652
    }
653

            
654
    #[test]
655
    fn word_boundaries_all_boundary_chars_selects_the_whole_run() {
656
        let text = "!!!???";
657
        assert_eq!(find_word_boundaries(text, 0), (0, 6));
658
        assert_eq!(find_word_boundaries(text, 3), (0, 6));
659
        assert_eq!(find_word_boundaries(text, 5), (0, 6));
660
    }
661

            
662
    #[test]
663
    fn word_boundaries_huge_text_with_max_offset_does_not_overflow() {
664
        let text = "a".repeat(64 * 1024);
665
        let (start, end) = find_word_boundaries(&text, usize::MAX);
666
        assert_eq!((start, end), (0, text.len()));
667

            
668
        // …and the same for a huge all-boundary text.
669
        let sep = " ".repeat(64 * 1024);
670
        let (start, end) = find_word_boundaries(&sep, usize::MAX);
671
        assert!(start <= end && end <= sep.len());
672
    }
673

            
674
    // ------------------------------------------------------------------
675
    // byte_offset_to_cluster_id — numeric: zero / min_max / overflow
676
    // ------------------------------------------------------------------
677

            
678
    #[test]
679
    fn byte_offset_to_cluster_id_empty_map_is_none_for_every_offset() {
680
        for off in [0, 1, usize::MAX / 2, usize::MAX] {
681
            assert_eq!(byte_offset_to_cluster_id(&[], off), None);
682
        }
683
    }
684

            
685
    /// Non-empty map ⇒ ALWAYS `Some` (offsets past the end fall back to the last
686
    /// cluster). Exhaustive over every offset in and beyond the mapped range.
687
    #[test]
688
    fn byte_offset_to_cluster_id_non_empty_map_is_always_some() {
689
        let map = [(gid(0, 0), 3), (gid(0, 3), 1), (gid(0, 4), 2)];
690
        let total: usize = map.iter().map(|(_, l)| l).sum();
691

            
692
        for off in (0..=total + 8).chain([usize::MAX - 1, usize::MAX]) {
693
            assert!(
694
                byte_offset_to_cluster_id(&map, off).is_some(),
695
                "offset {off} returned None for a non-empty map"
696
            );
697
        }
698
        assert_eq!(byte_offset_to_cluster_id(&map, 0), Some(gid(0, 0)));
699
        assert_eq!(byte_offset_to_cluster_id(&map, total - 1), Some(gid(0, 4)));
700
        assert_eq!(byte_offset_to_cluster_id(&map, usize::MAX), Some(gid(0, 4)));
701
    }
702

            
703
    /// PINNED QUIRK: a zero-length cluster is *unaddressable* — `offset < cum + 0`
704
    /// is never true — so it is silently skipped and the next cluster wins.
705
    #[test]
706
    fn byte_offset_to_cluster_id_zero_length_clusters_are_skipped() {
707
        let map = [(gid(0, 0), 0), (gid(0, 1), 2), (gid(0, 3), 0)];
708

            
709
        assert_eq!(
710
            byte_offset_to_cluster_id(&map, 0),
711
            Some(gid(0, 1)),
712
            "leading zero-length cluster must be skipped, not returned"
713
        );
714
        assert_eq!(byte_offset_to_cluster_id(&map, 1), Some(gid(0, 1)));
715
        // Past the end → last entry, even though the last entry is zero-length.
716
        assert_eq!(byte_offset_to_cluster_id(&map, 2), Some(gid(0, 3)));
717
    }
718

            
719
    #[test]
720
    fn byte_offset_to_cluster_id_all_zero_length_map_returns_last_never_none() {
721
        let map = [(gid(0, 0), 0), (gid(0, 1), 0), (gid(0, 2), 0)];
722
        for off in [0, 1, usize::MAX] {
723
            assert_eq!(byte_offset_to_cluster_id(&map, off), Some(gid(0, 2)));
724
        }
725
    }
726

            
727
    /// Cluster lengths at the top of the `usize` range: a single `usize::MAX`-long
728
    /// cluster, and two `usize::MAX / 2`-long ones whose running sum stays just
729
    /// below the overflow point. The internal `cumulative + len` must not wrap.
730
    #[test]
731
    fn byte_offset_to_cluster_id_huge_lengths_do_not_overflow() {
732
        let single = [(gid(0, 0), usize::MAX)];
733
        assert_eq!(byte_offset_to_cluster_id(&single, 0), Some(gid(0, 0)));
734
        assert_eq!(
735
            byte_offset_to_cluster_id(&single, usize::MAX - 1),
736
            Some(gid(0, 0))
737
        );
738
        // offset == len → falls through the loop, then to the `last()` fallback.
739
        assert_eq!(
740
            byte_offset_to_cluster_id(&single, usize::MAX),
741
            Some(gid(0, 0))
742
        );
743

            
744
        let half = usize::MAX / 2; // 2*half == usize::MAX - 1, no wrap.
745
        let pair = [(gid(0, 0), half), (gid(0, 1), half)];
746
        assert_eq!(byte_offset_to_cluster_id(&pair, 0), Some(gid(0, 0)));
747
        assert_eq!(byte_offset_to_cluster_id(&pair, half - 1), Some(gid(0, 0)));
748
        assert_eq!(byte_offset_to_cluster_id(&pair, half), Some(gid(0, 1)));
749
        assert_eq!(byte_offset_to_cluster_id(&pair, usize::MAX), Some(gid(0, 1)));
750
    }
751

            
752
    // ------------------------------------------------------------------
753
    // find_cluster_at_cursor — getters / predicates: invariants
754
    // ------------------------------------------------------------------
755

            
756
    #[test]
757
    fn find_cluster_at_cursor_empty_layout_is_none() {
758
        let layout = layout_of(vec![]);
759
        assert!(find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
760
        assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
761
    }
762

            
763
    #[test]
764
    fn find_cluster_at_cursor_unknown_id_is_none() {
765
        let layout = layout_from_str("abc", 0);
766
        // Right run, byte offset past the end.
767
        assert!(find_cluster_at_cursor(&cursor_at(gid(0, 99)), &layout).is_none());
768
        // Right byte offset, wrong run.
769
        assert!(find_cluster_at_cursor(&cursor_at(gid(7, 0)), &layout).is_none());
770
        // Saturated id.
771
        assert!(find_cluster_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none());
772
    }
773

            
774
    #[test]
775
    fn find_cluster_at_cursor_skips_non_cluster_items_and_reports_visual_index() {
776
        let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0), tab(0)]);
777
        let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
778
        assert_eq!(idx, 1, "index must be into layout.items, skipping the tab");
779
        assert_eq!(found.text(), "a");
780
    }
781

            
782
    /// Duplicate cluster ids are not supposed to happen; if they do, the FIRST
783
    /// visual match wins. Pinned so a change of iteration order is caught.
784
    #[test]
785
    fn find_cluster_at_cursor_duplicate_ids_return_the_first_match() {
786
        let layout = layout_of(vec![cl("x", gid(0, 0), 0), cl("y", gid(0, 0), 1)]);
787
        let (idx, found) = find_cluster_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
788
        assert_eq!(idx, 0);
789
        assert_eq!(found.text(), "x");
790
    }
791

            
792
    // ------------------------------------------------------------------
793
    // extract_line_text_and_clusters — numeric: index bounds / ordering
794
    // ------------------------------------------------------------------
795

            
796
    /// `layout.items[item_idx]` is an unchecked index: the function's contract is
797
    /// that `item_idx` comes from `find_cluster_at_cursor`. Pin the panic so that
798
    /// contract is not silently broadened.
799
    #[test]
800
    #[should_panic(expected = "index out of bounds")]
801
    fn extract_line_text_out_of_bounds_index_panics_on_empty_layout() {
802
        let layout = layout_of(vec![]);
803
        let _ = extract_line_text_and_clusters(0, &layout);
804
    }
805

            
806
    #[test]
807
    #[should_panic(expected = "index out of bounds")]
808
    fn extract_line_text_usize_max_index_panics() {
809
        let layout = layout_from_str("abc", 0);
810
        let _ = extract_line_text_and_clusters(usize::MAX, &layout);
811
    }
812

            
813
    #[test]
814
    fn extract_line_text_non_cluster_item_yields_empty_text_and_map() {
815
        let layout = layout_of(vec![tab(0), cl("a", gid(0, 0), 0)]);
816
        let (text, map) = extract_line_text_and_clusters(0, &layout);
817
        assert!(text.is_empty());
818
        assert!(map.is_empty());
819
    }
820

            
821
    #[test]
822
    fn extract_line_text_zero_index_on_a_cluster_gathers_the_whole_run() {
823
        let layout = layout_from_str("hi there", 0);
824
        let (text, map) = extract_line_text_and_clusters(0, &layout);
825
        assert_eq!(text, "hi there");
826
        assert_eq!(map.len(), 8);
827
        assert_eq!(map[0], (gid(0, 0), 1));
828
    }
829

            
830
    /// Documented behaviour: clusters are gathered by LOGICAL run and sorted by
831
    /// `start_byte_in_run`, so a visually reordered (bidi) item vector must still
832
    /// concatenate in logical order.
833
    #[test]
834
    fn extract_line_text_restores_logical_order_from_reversed_visual_items() {
835
        let layout = layout_of(vec![
836
            cl("o", gid(0, 4), 0),
837
            cl("l", gid(0, 3), 0),
838
            cl("l", gid(0, 2), 0),
839
            cl("e", gid(0, 1), 0),
840
            cl("H", gid(0, 0), 0),
841
        ]);
842
        let (text, map) = extract_line_text_and_clusters(0, &layout);
843
        assert_eq!(text, "Hello", "visual order must not leak into the text");
844
        assert_eq!(
845
            map.iter().map(|(id, _)| id.start_byte_in_run).collect::<Vec<_>>(),
846
            vec![0, 1, 2, 3, 4]
847
        );
848
    }
849

            
850
    /// Documented behaviour: gathering is NOT restricted to one visual line, so a
851
    /// word split by a soft wrap is still reassembled.
852
    #[test]
853
    fn extract_line_text_crosses_visual_lines_and_excludes_other_runs() {
854
        let layout = layout_of(vec![
855
            cl("H", gid(0, 0), 0),
856
            cl("e", gid(0, 1), 0),
857
            cl("l", gid(0, 2), 1), // soft-wrapped onto line 1
858
            cl("X", gid(1, 0), 1), // a different logical run — must be excluded
859
            cl("o", gid(0, 3), 2), // and onto line 2
860
        ]);
861
        let (text, map) = extract_line_text_and_clusters(0, &layout);
862
        assert_eq!(text, "Helo");
863
        assert_eq!(map.len(), 4);
864
        assert!(map.iter().all(|(id, _)| id.source_run == 0));
865
    }
866

            
867
    #[test]
868
    fn extract_line_text_byte_lengths_are_utf8_lengths_not_char_counts() {
869
        let layout = layout_from_str("é日👍", 0);
870
        let (text, map) = extract_line_text_and_clusters(0, &layout);
871
        assert_eq!(text, "é日👍");
872
        assert_eq!(
873
            map.iter().map(|(_, len)| *len).collect::<Vec<_>>(),
874
            vec![2, 3, 4]
875
        );
876
        assert_eq!(map.iter().map(|(_, l)| l).sum::<usize>(), text.len());
877
    }
878

            
879
    // ------------------------------------------------------------------
880
    // select_word_at_cursor — round-trip + invariants
881
    // ------------------------------------------------------------------
882

            
883
    #[test]
884
    fn select_word_empty_layout_is_none() {
885
        let layout = layout_of(vec![]);
886
        assert!(select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
887
    }
888

            
889
    #[test]
890
    fn select_word_unknown_cursor_is_none() {
891
        let layout = layout_from_str("Hello", 0);
892
        assert!(select_word_at_cursor(&cursor_at(gid(3, 0)), &layout).is_none());
893
        assert!(select_word_at_cursor(&cursor_at(gid(0, u32::MAX)), &layout).is_none());
894
    }
895

            
896
    #[test]
897
    fn select_word_selects_the_word_under_the_cursor_with_correct_affinities() {
898
        let layout = layout_from_str("Hello World", 0);
899
        let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
900

            
901
        assert_eq!(range.start.cluster_id, gid(0, 0), "start of \"Hello\"");
902
        assert_eq!(range.end.cluster_id, gid(0, 4), "last cluster of \"Hello\"");
903
        assert_eq!(range.start.affinity, CursorAffinity::Leading);
904
        assert_eq!(range.end.affinity, CursorAffinity::Trailing);
905

            
906
        let range = select_word_at_cursor(&cursor_at(gid(0, 8)), &layout).unwrap();
907
        assert_eq!(range.start.cluster_id, gid(0, 6));
908
        assert_eq!(range.end.cluster_id, gid(0, 10));
909
    }
910

            
911
    /// A word broken by a soft wrap must select whole, not just the clicked fragment.
912
    #[test]
913
    fn select_word_spans_a_soft_wrap() {
914
        let layout = layout_of(vec![
915
            cl("H", gid(0, 0), 0),
916
            cl("e", gid(0, 1), 0),
917
            cl("l", gid(0, 2), 0),
918
            cl("l", gid(0, 3), 1), // wrapped
919
            cl("o", gid(0, 4), 1),
920
        ]);
921
        let range = select_word_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
922
        assert_eq!(range.start.cluster_id, gid(0, 0));
923
        assert_eq!(range.end.cluster_id, gid(0, 4), "must cross the line break");
924
    }
925

            
926
    /// Bidi: items in visual (reversed) order must still yield the logical word.
927
    #[test]
928
    fn select_word_uses_logical_not_visual_order() {
929
        let layout = layout_of(vec![
930
            cl("o", gid(0, 4), 0),
931
            cl("l", gid(0, 3), 0),
932
            cl("l", gid(0, 2), 0),
933
            cl("e", gid(0, 1), 0),
934
            cl("H", gid(0, 0), 0),
935
        ]);
936
        let range = select_word_at_cursor(&cursor_at(gid(0, 3)), &layout).unwrap();
937
        assert_eq!(range.start.cluster_id, gid(0, 0));
938
        assert_eq!(range.end.cluster_id, gid(0, 4));
939
    }
940

            
941
    /// Round-trip / idempotence: re-selecting from the START of a returned range
942
    /// must reproduce the identical range. A fixpoint failure here would make
943
    /// double-click-then-drag jitter.
944
    #[test]
945
    fn select_word_is_idempotent_from_its_own_start_cursor() {
946
        for text in ["Hello, World! foo_bar 42", "a  b", "héllo wörld", "!!!a"] {
947
            let layout = layout_from_str(text, 0);
948

            
949
            for (byte_idx, _) in text.char_indices() {
950
                let cur = cursor_at(gid(0, byte_idx as u32));
951
                let first = select_word_at_cursor(&cur, &layout)
952
                    .unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: no selection"));
953
                let again = select_word_at_cursor(&first.start, &layout)
954
                    .unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: re-select failed"));
955

            
956
                assert_eq!(
957
                    first, again,
958
                    "{text:?} @ {byte_idx}: selection is not a fixpoint"
959
                );
960
            }
961
        }
962
    }
963

            
964
    /// Invariants over every reachable cursor of every nasty string: always `Some`,
965
    /// never inverted, both endpoints are real clusters of the layout, affinities fixed.
966
    #[test]
967
    fn select_word_invariants_hold_for_every_cursor_of_nasty_unicode() {
968
        for &text in NASTY {
969
            let layout = layout_from_str(text, 0);
970
            let ids: Vec<GraphemeClusterId> = text
971
                .char_indices()
972
                .map(|(b, _)| gid(0, b as u32))
973
                .collect();
974

            
975
            for id in &ids {
976
                let range = select_word_at_cursor(&cursor_at(*id), &layout)
977
                    .unwrap_or_else(|| panic!("{text:?} @ {id:?}: expected a selection"));
978

            
979
                assert!(
980
                    range.start.cluster_id <= range.end.cluster_id,
981
                    "{text:?} @ {id:?}: inverted range {range:?}"
982
                );
983
                assert!(
984
                    ids.contains(&range.start.cluster_id),
985
                    "{text:?} @ {id:?}: start is not a cluster of the layout"
986
                );
987
                assert!(
988
                    ids.contains(&range.end.cluster_id),
989
                    "{text:?} @ {id:?}: end is not a cluster of the layout"
990
                );
991
                assert_eq!(range.start.affinity, CursorAffinity::Leading);
992
                assert_eq!(range.end.affinity, CursorAffinity::Trailing);
993
            }
994
        }
995
    }
996

            
997
    /// A zero-length cluster (empty `text`) cannot be addressed by a byte offset,
998
    /// so selecting *on* it resolves to the neighbouring cluster instead of panicking.
999
    #[test]
    fn select_word_on_zero_length_cluster_resolves_to_a_neighbour() {
        let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("x", gid(0, 1), 0)]);
        let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
        assert_eq!(range.start.cluster_id, gid(0, 1));
        assert_eq!(range.end.cluster_id, gid(0, 1));
    }
    #[test]
    fn select_word_all_clusters_empty_does_not_panic() {
        let layout = layout_of(vec![cl("", gid(0, 0), 0), cl("", gid(0, 1), 0)]);
        let range = select_word_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
        // Empty concatenated text ⇒ boundaries (0, 0) ⇒ both ends fall back to last().
        assert_eq!(range.start.cluster_id, gid(0, 1));
        assert_eq!(range.end.cluster_id, gid(0, 1));
    }
    /// Cluster metadata that contradicts the cluster text (`start_byte_in_run`
    /// values that do not match the utf-8 lengths) must not panic or slice mid-char.
    #[test]
    fn select_word_with_inconsistent_cluster_metadata_does_not_panic() {
        let layout = layout_of(vec![
            cl("abc", gid(0, 0), 0), // claims 1 byte, is 3
            cl("def", gid(0, 1), 0),
            cl("👍", gid(0, 2), 0), // multi-byte at a bogus offset
        ]);
        for id in [gid(0, 0), gid(0, 1), gid(0, 2)] {
            let range = select_word_at_cursor(&cursor_at(id), &layout);
            assert!(range.is_some(), "{id:?} must still resolve");
        }
    }
    #[test]
    fn select_word_large_layout_stays_correct_and_does_not_panic() {
        // 4000 clusters: 500 × "word " (word chars + a separator).
        let text = "word ".repeat(800);
        let layout = layout_from_str(&text, 0);
        // Cursor in the middle of the 400th word.
        let word_start = 400 * 5;
        let range = select_word_at_cursor(&cursor_at(gid(0, word_start as u32 + 2)), &layout)
            .expect("mid-word cursor must select");
        assert_eq!(range.start.cluster_id, gid(0, word_start as u32));
        assert_eq!(range.end.cluster_id, gid(0, word_start as u32 + 3));
        // Cursor on the separator selects just the separator.
        let sep = word_start + 4;
        let range = select_word_at_cursor(&cursor_at(gid(0, sep as u32)), &layout)
            .expect("separator cursor must select");
        assert_eq!(range.start.cluster_id, gid(0, sep as u32));
        assert_eq!(range.end.cluster_id, gid(0, sep as u32));
    }
    // ------------------------------------------------------------------
    // select_paragraph_at_cursor — invariants
    // ------------------------------------------------------------------
    #[test]
    fn select_paragraph_empty_layout_is_none() {
        let layout = layout_of(vec![]);
        assert!(select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).is_none());
    }
    #[test]
    fn select_paragraph_unknown_cursor_is_none() {
        let layout = layout_from_str("abc", 0);
        assert!(select_paragraph_at_cursor(&cursor_at(gid(9, 9)), &layout).is_none());
        assert!(
            select_paragraph_at_cursor(&cursor_at(gid(u32::MAX, u32::MAX)), &layout).is_none()
        );
    }
    #[test]
    fn select_paragraph_covers_the_whole_soft_wrapped_paragraph() {
        // One paragraph wrapped over two visual lines. Triple-click selects the
        // paragraph — filtering by `line_index` used to select the clicked
        // visual line only, which no editor does.
        let layout = layout_of(vec![
            cl("a", gid(0, 0), 0),
            cl("b", gid(0, 1), 0),
            cl("c", gid(0, 2), 1),
            cl("d", gid(0, 3), 1),
        ]);
        for probe in [gid(0, 0), gid(0, 1), gid(0, 2), gid(0, 3)] {
            let range = select_paragraph_at_cursor(&cursor_at(probe), &layout).unwrap();
            assert_eq!(range.start.cluster_id, gid(0, 0), "from {probe:?}");
            assert_eq!(range.end.cluster_id, gid(0, 3), "from {probe:?}");
            assert_eq!(range.start.affinity, CursorAffinity::Leading);
            assert_eq!(range.end.affinity, CursorAffinity::Trailing);
        }
    }
    /// A paragraph made of several logical runs (`<p>plain <b>bold</b></p>`)
    /// selects whole — the runs are one paragraph, no break separates them.
    #[test]
    fn select_paragraph_spans_every_run_between_breaks() {
        let layout = layout_of(vec![
            cl("a", gid(0, 0), 0),
            cl("b", gid(1, 0), 0),
            cl("c", gid(2, 0), 1),
        ]);
        for probe in [gid(0, 0), gid(1, 0), gid(2, 0)] {
            let range = select_paragraph_at_cursor(&cursor_at(probe), &layout).unwrap();
            assert_eq!(range.start.cluster_id, gid(0, 0), "from {probe:?}");
            assert_eq!(range.end.cluster_id, gid(2, 0), "from {probe:?}");
        }
    }
    /// A HARD break (`<br>`, a preserved newline) IS a paragraph boundary: it
    /// occupies its own slot in the inline-content array, so its `run_index`
    /// splits the clusters into two paragraphs.
    #[test]
    fn select_paragraph_stops_at_hard_breaks() {
        let layout = layout_of(vec![
            cl("a", gid(0, 0), 0),
            cl("b", gid(0, 1), 0),
            hard_break(1, 0),
            cl("c", gid(2, 0), 1),
            cl("d", gid(2, 1), 2), // wrapped: still the second paragraph
            hard_break(3, 2),
            cl("e", gid(4, 0), 3),
        ]);
        let first = select_paragraph_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
        assert_eq!(first.start.cluster_id, gid(0, 0));
        assert_eq!(first.end.cluster_id, gid(0, 1), "must not cross the break");
        let middle = select_paragraph_at_cursor(&cursor_at(gid(2, 1)), &layout).unwrap();
        assert_eq!(middle.start.cluster_id, gid(2, 0), "bounded on both sides");
        assert_eq!(middle.end.cluster_id, gid(2, 1));
        let last = select_paragraph_at_cursor(&cursor_at(gid(4, 0)), &layout).unwrap();
        assert_eq!(last.start.cluster_id, gid(4, 0));
        assert_eq!(last.end.cluster_id, gid(4, 0));
    }
    /// A SOFT break is a wrap opportunity, not a paragraph boundary.
    #[test]
    fn select_paragraph_ignores_soft_breaks() {
        let layout = layout_of(vec![
            cl("a", gid(0, 0), 0),
            soft_break(1, 0),
            cl("b", gid(2, 0), 1),
        ]);
        let range = select_paragraph_at_cursor(&cursor_at(gid(0, 0)), &layout).unwrap();
        assert_eq!(range.start.cluster_id, gid(0, 0));
        assert_eq!(range.end.cluster_id, gid(2, 0));
    }
    #[test]
    fn select_paragraph_ignores_non_cluster_items_at_the_paragraph_edges() {
        let layout = layout_of(vec![
            tab(0),
            cl("a", gid(0, 0), 0),
            cl("b", gid(0, 1), 0),
            tab(0),
        ]);
        let range = select_paragraph_at_cursor(&cursor_at(gid(0, 1)), &layout).unwrap();
        assert_eq!(range.start.cluster_id, gid(0, 0));
        assert_eq!(range.end.cluster_id, gid(0, 1));
    }
    /// A saturated `line_index` is no longer load-bearing (the paragraph is
    /// delimited by breaks, not by lines), and must not change the answer or
    /// overflow anything.
    #[test]
    fn select_paragraph_is_unaffected_by_a_saturated_line_index() {
        let layout = layout_of(vec![
            cl("a", gid(0, 0), 0),
            cl("b", gid(0, 1), usize::MAX),
            cl("c", gid(0, 2), usize::MAX),
        ]);
        for probe in [gid(0, 0), gid(0, 1), gid(0, 2)] {
            let range = select_paragraph_at_cursor(&cursor_at(probe), &layout).unwrap();
            assert_eq!(range.start.cluster_id, gid(0, 0), "from {probe:?}");
            assert_eq!(range.end.cluster_id, gid(0, 2), "from {probe:?}");
        }
    }
    /// Bidi: the items arrive in VISUAL (reversed) order, but the endpoints are
    /// taken in LOGICAL order, so the range is never inverted. It used to read
    /// the ends of the visual vector and return `start > end`, which
    /// `get_selection_rects` (a logical walk) cannot highlight — an inverted
    /// range is a bug, not a quirk.
    #[test]
    fn select_paragraph_returns_a_logical_range_for_reordered_runs() {
        let layout = layout_of(vec![
            cl("o", gid(0, 4), 0),
            cl("l", gid(0, 3), 0),
            cl("l", gid(0, 2), 0),
            cl("e", gid(0, 1), 0),
            cl("H", gid(0, 0), 0),
        ]);
        let range = select_paragraph_at_cursor(&cursor_at(gid(0, 2)), &layout).unwrap();
        assert_eq!(range.start.cluster_id, gid(0, 0), "logically-first cluster");
        assert_eq!(range.end.cluster_id, gid(0, 4), "logically-last cluster");
        assert!(
            range.start.cluster_id <= range.end.cluster_id,
            "a selection range must never be logically inverted"
        );
    }
    /// The same invariant `select_word_at_cursor` is held to, over every
    /// reachable cursor of every nasty string.
    #[test]
    fn select_paragraph_is_never_inverted_for_nasty_unicode() {
        for &text in NASTY {
            let layout = layout_from_str(text, 0);
            for (byte_idx, _) in text.char_indices() {
                let cur = cursor_at(gid(0, byte_idx as u32));
                let range = select_paragraph_at_cursor(&cur, &layout)
                    .unwrap_or_else(|| panic!("{text:?} @ {byte_idx}: expected a selection"));
                assert!(
                    range.start.cluster_id <= range.end.cluster_id,
                    "{text:?} @ {byte_idx}: inverted range {range:?}"
                );
            }
        }
    }
    /// Whenever the cursor resolves to a cluster, paragraph selection must resolve
    /// too (its line always contains at least that cluster) — never `None`.
    #[test]
    fn select_paragraph_is_some_for_every_reachable_cursor() {
        for &text in NASTY {
            let layout = layout_from_str(text, 0);
            for (byte_idx, _) in text.char_indices() {
                let cur = cursor_at(gid(0, byte_idx as u32));
                assert!(
                    select_paragraph_at_cursor(&cur, &layout).is_some(),
                    "{text:?} @ {byte_idx}: cursor found a cluster but no paragraph"
                );
            }
        }
    }
}