Skip to main content

cranpose_ui/
text_selection.rs

1//! Native-grade text selection primitives for `BasicTextField`.
2//!
3//! This module holds the pure, unit-tested building blocks the text field uses
4//! to offer Android/iOS-style selection: tap-count classification, word and
5//! line/paragraph boundary detection, and the geometry of the draggable
6//! teardrop selection handles (their shapes, their hit regions, and the
7//! selection math that a handle drag produces).
8//!
9//! Keeping these as free functions makes the touch behavior testable without a
10//! renderer and keeps `TextFieldModifierNode` focused on wiring.
11
12/// Maximum time between taps that still counts as a multi-tap, in milliseconds.
13pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
14
15/// Maximum distance (px) between consecutive taps that still counts as a
16/// multi-tap. A tap that lands far from the previous one starts a fresh
17/// single tap even if it arrives quickly, matching Android's `ViewConfiguration`
18/// double-tap slop behavior.
19pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
20
21/// The unit of text a tap gesture selects, growing with the tap count the way
22/// mature text editors do (Android `TextView`, iOS `UITextView`, VS Code):
23///
24/// * 1 tap → [`Caret`](SelectionGranularity::Caret) (place the cursor);
25/// * 2 taps → [`Word`](SelectionGranularity::Word);
26/// * 3 taps → [`Line`](SelectionGranularity::Line);
27/// * 4 taps → [`Paragraph`](SelectionGranularity::Paragraph);
28/// * 5+ taps → cycle back through word → line → paragraph.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum SelectionGranularity {
31    /// Collapsed caret (a single tap places the cursor).
32    Caret,
33    /// The word under the tap.
34    Word,
35    /// The line under the tap (delimited by `\n`).
36    Line,
37    /// The paragraph under the tap (delimited by blank lines).
38    Paragraph,
39}
40
41/// Classifies a press into a 1-based tap count from the previous tap's count,
42/// the time since it, and the distance from it.
43///
44/// `previous` is the last tap's `(count, x, y)` or `None` for the first tap. A
45/// tap increments the count only when it lands within both the timeout and the
46/// slop radius; otherwise it restarts at `1`. The count is **not** wrapped here
47/// — the granularity mapping ([`tap_selection_granularity`]) cycles instead, so
48/// the field can keep escalating (word → line → paragraph → word …) as long as
49/// the finger keeps tapping in place.
50pub fn classify_tap_count(
51    previous: Option<(u8, f32, f32)>,
52    elapsed_ms: u128,
53    x: f32,
54    y: f32,
55    timeout_ms: u128,
56    slop_px: f32,
57) -> u8 {
58    let Some((prev_count, prev_x, prev_y)) = previous else {
59        return 1;
60    };
61    let within_time = elapsed_ms <= timeout_ms;
62    let dx = x - prev_x;
63    let dy = y - prev_y;
64    let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
65    if !within_time || !within_slop {
66        return 1;
67    }
68    prev_count.saturating_add(1)
69}
70
71/// Resolves the effective tap count for a press, folding in the "tap inside an
72/// existing selection" gesture so it drives the same word → line → paragraph
73/// granularity ladder ([`tap_selection_granularity`]) as a rapid multi-tap.
74///
75/// Inputs:
76/// * `raw_tap_count` — the time-and-slop-gated multi-tap count from
77///   [`classify_tap_count`] (2+ means a genuine rapid multi-tap in progress);
78/// * `previous_count` — the effective count the *previous* press resolved to
79///   (the field remembers it as its click count);
80/// * `tap_in_selection` — the press landed inside the current, non-collapsed
81///   selection;
82/// * `repeat_in_place` — the press landed within the multi-tap slop of the
83///   previous press, **independent of timing** (the same spot, tapped again).
84///
85/// Behavior:
86/// * a rapid multi-tap (`raw_tap_count >= 2`) uses its own running count, so
87///   double→word, triple→line, … keep working exactly as before;
88/// * a lone tap inside a selection selects the word under the finger, and each
89///   further tap at the *same spot* climbs the ladder (word → line → paragraph →
90///   word …) even when it arrives slowly (the multi-tap timeout has lapsed) —
91///   users tap-then-look-then-tap, so the growth is keyed on location, not time;
92/// * a lone tap at a *new* spot inside the selection re-grabs that word (resets
93///   to word); and
94/// * a lone tap outside any selection is left as-is (a single tap → caret).
95pub fn resolve_selection_tap_count(
96    raw_tap_count: u8,
97    previous_count: u8,
98    tap_in_selection: bool,
99    repeat_in_place: bool,
100) -> u8 {
101    if raw_tap_count >= 2 {
102        raw_tap_count
103    } else if tap_in_selection {
104        if repeat_in_place {
105            // Keep climbing the granularity ladder at the same spot.
106            previous_count.max(1).saturating_add(1)
107        } else {
108            // First tap inside the selection (or a tap on a different word):
109            // grab the word under the finger.
110            2
111        }
112    } else {
113        raw_tap_count
114    }
115}
116
117/// Maps a 1-based tap count to the granularity it selects.
118///
119/// A single tap places the caret; two taps select the word, three the line,
120/// four the paragraph, and every further tap cycles back through
121/// word → line → paragraph so a resting finger keeps toggling between the three
122/// range granularities (matching desktop editors and iOS).
123pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
124    match tap_count {
125        0 | 1 => SelectionGranularity::Caret,
126        n => match (n - 2) % 3 {
127            0 => SelectionGranularity::Word,
128            1 => SelectionGranularity::Line,
129            _ => SelectionGranularity::Paragraph,
130        },
131    }
132}
133
134/// Returns the byte range `[start, end)` of the line containing `pos`, delimited
135/// by `\n` (the newline itself is excluded from the range).
136///
137/// Used for triple-tap line selection. Byte offsets always land on `char`
138/// boundaries because `\n` is a single-byte ASCII character.
139pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
140    let pos = pos.min(text.len());
141    let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
142    let end = text[pos..]
143        .find('\n')
144        .map(|i| pos + i)
145        .unwrap_or(text.len());
146    (start, end)
147}
148
149/// Returns the byte range `[start, end)` of the paragraph containing `pos`.
150///
151/// Paragraphs are delimited by blank lines — a run of two or more consecutive
152/// `\n` — so a fourth tap grows the selection from one line to the whole block
153/// of text around it. Text with no blank line is a single paragraph (the whole
154/// string). Byte offsets land on `char` boundaries because `\n` is single-byte
155/// ASCII. Unicode-aware: multi-byte characters inside the paragraph are spanned
156/// whole.
157pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
158    let pos = pos.min(text.len());
159    // Start: just after the last blank-line separator at or before `pos`.
160    let start = text[..pos]
161        .rfind("\n\n")
162        .map(|i| {
163            // Skip the whole run of blank lines so the paragraph starts on its
164            // first non-empty line.
165            let mut s = i + 1;
166            while text[s..].starts_with('\n') {
167                s += 1;
168            }
169            s
170        })
171        .unwrap_or(0);
172    // End: the next blank-line separator at or after `pos`.
173    let end = text[pos..]
174        .find("\n\n")
175        .map(|i| pos + i)
176        .unwrap_or(text.len());
177    (start.min(end), end)
178}
179
180/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
181/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
182/// sits on.
183///
184/// The caret belongs to the last visual line whose start is at or before
185/// `offset`, so:
186/// * a caret in the middle of a visual line resolves to that line;
187/// * a caret at a soft-wrap boundary sits at the start of the lower line;
188/// * a caret at the very end of the text sits on the last visual line.
189///
190/// This is the wrap-aware replacement for counting logical `\n` lines: without
191/// it, a caret on a wrapped line's second visual line is drawn on the first (and
192/// its x runs off the right edge), even though typing and the magnifier place it
193/// correctly. Returns `(0, 0)` when there are no ranges.
194pub fn caret_visual_line(ranges: &[std::ops::Range<usize>], offset: usize) -> (usize, usize) {
195    let mut result = (0usize, 0usize);
196    for (index, range) in ranges.iter().enumerate() {
197        if range.start <= offset {
198            result = (index, range.start);
199        } else {
200            break;
201        }
202    }
203    result
204}
205
206/// Which selection handle a teardrop represents.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
208pub enum HandleKind {
209    /// The blinking-cursor handle shown for a collapsed selection: a teardrop
210    /// whose tip points up at the cursor, centered under it.
211    Cursor,
212    /// The start (leftmost) selection handle: tip at the top-right, bulb below-left.
213    SelectionStart,
214    /// The end (rightmost) selection handle: tip at the top-left, bulb below-right.
215    SelectionEnd,
216}
217
218/// Radius of a selection/cursor handle bulb in px (Android uses ~11dp).
219pub const HANDLE_RADIUS: f32 = 8.0;
220
221/// SVG path data for a handle teardrop whose tip sits at `(tip_x, tip_y)`.
222///
223/// The tip is anchored at the text edge (the cursor position or a selection
224/// endpoint at the line's bottom) and the rounded bulb hangs below it, so the
225/// caller positions the handle by passing the on-screen anchor point.
226pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
227    let r = radius.max(0.0);
228    let cy = tip_y + r; // bulb center y
229    match kind {
230        HandleKind::Cursor => {
231            // Symmetric teardrop: tip up, full circular bulb below.
232            format!(
233                "M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
234                left = tip_x - r,
235                right = tip_x + r,
236            )
237        }
238        HandleKind::SelectionStart => {
239            // Android start (left) handle: the point sits at the TOP-RIGHT
240            // (touching the selection start) with a straight vertical right edge,
241            // and the round bulb hangs down and to the LEFT. Traced tip → straight
242            // down the right edge → 270° arc round the bulb (whose centre sits at
243            // `(tip_x - r, cy)`, down-left of the tip) → back along the top edge to
244            // the tip.
245            //
246            // The sweep flag is `1` (clockwise, y-down): together with the
247            // large-arc flag this centres the arc on the bulb below-left of the
248            // tip. Sweep `0` would instead centre the arc on the tip itself,
249            // drawing an upward pac-man wedge that overlaps the glyph line — the
250            // reported "inverted teardrop".
251            format!(
252                "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 1 {left} {tip_y} Z",
253                left = tip_x - r,
254            )
255        }
256        HandleKind::SelectionEnd => {
257            // Android end (right) handle: the exact mirror of the start handle —
258            // the point sits at the TOP-LEFT (touching the selection end) with a
259            // straight vertical left edge, and the round bulb hangs down and to
260            // the RIGHT. Same trace as the start handle with the arc swept the
261            // other way (sweep `0`) so it is a true reflection (not rotated): the
262            // bulb centre sits at `(tip_x + r, cy)`, down-right of the tip.
263            format!(
264                "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {right} {tip_y} Z",
265                right = tip_x + r,
266            )
267        }
268    }
269}
270
271/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
272/// its touch target, matching Android's generous handle hit area. A bare
273/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
274/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
275/// slop the press falls through to the field below and places a caret, which
276/// collapses the selection. The slop is applied to the sides and BELOW the tip
277/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
278/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
279/// so a double-tap still reaches the field to escalate into a word selection.
280pub const HANDLE_GRAB_SLOP: f32 = 24.0;
281
282/// Computes the selection `(min, max)` that results from dragging one handle to
283/// a new text `offset`, keeping the opposite (fixed) edge anchored.
284///
285/// Dragging never lets the two edges cross: a dragged start clamps to just
286/// before the fixed end, and a dragged end clamps to just after the fixed
287/// start, so the selection keeps at least one selected unit.
288pub fn selection_after_handle_drag(
289    dragged: HandleKind,
290    fixed_edge: usize,
291    dragged_offset: usize,
292    text_len: usize,
293) -> (usize, usize) {
294    let fixed = fixed_edge.min(text_len);
295    let dragged_offset = dragged_offset.min(text_len);
296    match dragged {
297        HandleKind::SelectionStart => {
298            let start = dragged_offset.min(fixed.saturating_sub(1));
299            (start, fixed)
300        }
301        HandleKind::SelectionEnd => {
302            let end = dragged_offset.max(fixed + 1).min(text_len);
303            (fixed, end)
304        }
305        // The cursor handle just moves the collapsed caret.
306        HandleKind::Cursor => (dragged_offset, dragged_offset),
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn tap_classification_escalates_within_time_and_slop() {
316        assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
317        assert_eq!(
318            classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
319            2
320        );
321        assert_eq!(
322            classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
323            3
324        );
325        // A fourth in-place tap keeps counting up (the granularity mapping is
326        // what cycles, not the raw count).
327        assert_eq!(
328            classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
329            4
330        );
331        assert_eq!(
332            classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
333            5
334        );
335    }
336
337    #[test]
338    fn tap_classification_resets_past_timeout_or_slop() {
339        // Too slow: restarts.
340        assert_eq!(
341            classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
342            1
343        );
344        // Too far: restarts even though it is quick.
345        assert_eq!(
346            classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
347            1
348        );
349        // A reset also applies from a higher count.
350        assert_eq!(
351            classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
352            1
353        );
354    }
355
356    /// The tap-inside-selection ladder (bug c): a lone tap inside an existing
357    /// selection grabs the word, and every further tap AT THE SAME SPOT grows
358    /// the granularity word → line → paragraph, then cycles back to word — even
359    /// when the taps arrive too slowly to count as a rapid multi-tap (the growth
360    /// is keyed on location, not the double-tap timeout). Tapping a NEW spot
361    /// resets to word.
362    #[test]
363    fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
364        use SelectionGranularity::*;
365
366        // Start: a lone (slow) tap inside a selection. raw_tap_count == 1
367        // (the timeout lapsed), but it still grabs the word.
368        let mut count = resolve_selection_tap_count(1, 0, true, false);
369        assert_eq!(count, 2);
370        assert_eq!(tap_selection_granularity(count), Word);
371
372        // Same spot again, still slow (raw == 1): grow to the line.
373        count = resolve_selection_tap_count(1, count, true, true);
374        assert_eq!(count, 3);
375        assert_eq!(tap_selection_granularity(count), Line);
376
377        // Same spot again: grow to the paragraph.
378        count = resolve_selection_tap_count(1, count, true, true);
379        assert_eq!(count, 4);
380        assert_eq!(tap_selection_granularity(count), Paragraph);
381
382        // Same spot again: cycle back to the word.
383        count = resolve_selection_tap_count(1, count, true, true);
384        assert_eq!(count, 5);
385        assert_eq!(tap_selection_granularity(count), Word);
386
387        // A tap at a NEW spot inside the selection resets to word.
388        let reset = resolve_selection_tap_count(1, count, true, false);
389        assert_eq!(reset, 2);
390        assert_eq!(tap_selection_granularity(reset), Word);
391    }
392
393    /// A genuine rapid multi-tap keeps using its own running count, so
394    /// [`resolve_selection_tap_count`] does not disturb the double→word,
395    /// triple→line ladder, and a lone tap outside a selection stays a caret.
396    #[test]
397    fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
398        // Rapid multi-tap: pass the classify count straight through.
399        assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
400        assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
401        // Lone tap outside any selection: caret (count 1).
402        assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
403    }
404
405    #[test]
406    fn tap_granularity_grows_then_cycles() {
407        use SelectionGranularity::*;
408        assert_eq!(tap_selection_granularity(0), Caret);
409        assert_eq!(tap_selection_granularity(1), Caret);
410        assert_eq!(tap_selection_granularity(2), Word);
411        assert_eq!(tap_selection_granularity(3), Line);
412        assert_eq!(tap_selection_granularity(4), Paragraph);
413        // Fifth tap cycles back to word, then line, then paragraph again.
414        assert_eq!(tap_selection_granularity(5), Word);
415        assert_eq!(tap_selection_granularity(6), Line);
416        assert_eq!(tap_selection_granularity(7), Paragraph);
417        assert_eq!(tap_selection_granularity(8), Word);
418    }
419
420    #[test]
421    fn paragraph_boundaries_span_blank_line_delimited_blocks() {
422        let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
423        // Inside the first paragraph (two lines).
424        let (s, e) = find_paragraph_boundaries(text, 3);
425        assert_eq!(&text[s..e], "line one\nline two");
426        // Inside the second paragraph.
427        let (s, e) = find_paragraph_boundaries(text, 20);
428        assert_eq!(&text[s..e], "second para\nstill second");
429        // Inside the third paragraph, after a run of THREE newlines.
430        let (s, e) = find_paragraph_boundaries(text, text.len());
431        assert_eq!(&text[s..e], "third");
432    }
433
434    #[test]
435    fn paragraph_boundaries_no_blank_line_is_whole_text() {
436        let text = "just\none\nblock";
437        assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
438    }
439
440    #[test]
441    fn paragraph_boundaries_are_unicode_aware() {
442        // Multi-byte characters must be spanned whole and offsets stay on char
443        // boundaries.
444        let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
445        let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
446        let (s, e) = find_paragraph_boundaries(text, 3);
447        assert_eq!(&text[s..e], first);
448        assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
449    }
450
451    #[test]
452    fn line_boundaries_span_between_newlines() {
453        let text = "first line\nsecond line\nthird";
454        // Inside the second line.
455        assert_eq!(find_line_boundaries(text, 15), (11, 22));
456        // Start of the first line.
457        assert_eq!(find_line_boundaries(text, 0), (0, 10));
458        // Inside the last (newline-terminated-absent) line.
459        assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
460    }
461
462    #[test]
463    fn line_boundaries_handle_unicode_and_empty_lines() {
464        let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
465        // Empty middle line: start == end at the byte after the first newline.
466        let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
467        assert_eq!(start, end);
468        // Last line spans the two CJK characters.
469        let last = find_line_boundaries(text, text.len());
470        assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
471    }
472
473    #[test]
474    fn handle_path_is_non_empty_and_contains_the_tip() {
475        for kind in [
476            HandleKind::Cursor,
477            HandleKind::SelectionStart,
478            HandleKind::SelectionEnd,
479        ] {
480            let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
481            let path = cranpose_ui_graphics::VectorPath::parse(&data)
482                .expect("handle path must be valid SVG");
483            assert!(!path.is_empty(), "{kind:?} handle must have geometry");
484            let bounds = path.bounds();
485            // The tip (40, 20) must lie within the shape's bounds.
486            assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
487            assert!(bounds.y <= 20.0 + 0.5);
488            // The bulb hangs below the tip.
489            assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
490        }
491    }
492
493    /// Regression for the inverted-teardrop bug: the drawn selection handles
494    /// must have the correct Android orientation — the whole teardrop sits AT OR
495    /// BELOW the tip line (never above it, into the glyphs), and each handle's
496    /// bulb hangs to the correct side of its tip:
497    ///
498    /// * start (left) handle — point top-right, bulb down-LEFT (all geometry at
499    ///   or left of the tip's x);
500    /// * end (right) handle — point top-left, bulb down-RIGHT (all geometry at or
501    ///   right of the tip's x);
502    /// * cursor handle — symmetric, centred on the tip.
503    ///
504    /// A sweep-flag mistake used to centre the arc on the tip, producing an
505    /// upward pac-man wedge that extended ABOVE the tip and to the wrong side —
506    /// exactly what this guards against.
507    #[test]
508    fn selection_handles_point_at_the_tip_with_the_bulb_below() {
509        let (tip_x, tip_y, r) = (40.0_f32, 20.0_f32, HANDLE_RADIUS);
510        let eps = 0.5_f32;
511
512        let sample_points = |kind: HandleKind| -> Vec<cranpose_ui_graphics::Point> {
513            let data = handle_path_data(kind, tip_x, tip_y, r);
514            let path = cranpose_ui_graphics::VectorPath::parse(&data).expect("valid handle path");
515            path.subpaths().iter().flatten().copied().collect()
516        };
517
518        // No handle draws any geometry ABOVE the tip line — that region belongs to
519        // the glyphs, and a handle poking up into it is the inverted-teardrop bug.
520        for kind in [
521            HandleKind::Cursor,
522            HandleKind::SelectionStart,
523            HandleKind::SelectionEnd,
524        ] {
525            for p in sample_points(kind) {
526                assert!(
527                    p.y >= tip_y - eps,
528                    "{kind:?}: point {p:?} is above the tip line y={tip_y} (teardrop inverted)"
529                );
530            }
531        }
532
533        // Start bulb hangs down-LEFT: every point is at or left of the tip's x,
534        // and the shape reaches a full bulb-width to the left.
535        let start = sample_points(HandleKind::SelectionStart);
536        assert!(
537            start.iter().all(|p| p.x <= tip_x + eps),
538            "start handle must not extend right of its tip"
539        );
540        assert!(
541            start.iter().any(|p| p.x <= tip_x - 2.0 * r + eps),
542            "start handle bulb must reach a full diameter to the LEFT of the tip"
543        );
544
545        // End bulb hangs down-RIGHT: mirror image of the start handle.
546        let end = sample_points(HandleKind::SelectionEnd);
547        assert!(
548            end.iter().all(|p| p.x >= tip_x - eps),
549            "end handle must not extend left of its tip"
550        );
551        assert!(
552            end.iter().any(|p| p.x >= tip_x + 2.0 * r - eps),
553            "end handle bulb must reach a full diameter to the RIGHT of the tip"
554        );
555
556        // Cursor handle is symmetric about the tip: it reaches ~r to each side.
557        let cursor = sample_points(HandleKind::Cursor);
558        assert!(
559            cursor.iter().any(|p| p.x <= tip_x - r + eps)
560                && cursor.iter().any(|p| p.x >= tip_x + r - eps),
561            "cursor handle must be symmetric about the tip"
562        );
563    }
564
565    /// The wrap-aware caret line lookup (bug d): a caret on a wrapped line's
566    /// later visual line must resolve to that visual line (not the logical
567    /// line's first visual line), with the correct line-start byte so its x is
568    /// measured from the start of the visual line.
569    #[test]
570    fn caret_visual_line_resolves_wrapped_visual_lines() {
571        // "aaaa bbbb" wrapped into ["aaaa " (0..5), "bbbb" (5..9)], then a hard
572        // newline to a short line "cc" (10..12).
573        let ranges = vec![0..5usize, 5..9, 10..12];
574
575        // Start of the first visual line.
576        assert_eq!(caret_visual_line(&ranges, 0), (0, 0));
577        // Middle of the first visual line.
578        assert_eq!(caret_visual_line(&ranges, 3), (0, 0));
579        // Start of the second (wrapped) visual line.
580        assert_eq!(caret_visual_line(&ranges, 5), (1, 5));
581        // Middle of the second visual line — must NOT resolve to line 0.
582        assert_eq!(caret_visual_line(&ranges, 7), (1, 5));
583        // End of the wrapped logical line.
584        assert_eq!(caret_visual_line(&ranges, 9), (1, 5));
585        // The line after the hard newline.
586        assert_eq!(caret_visual_line(&ranges, 11), (2, 10));
587        // End of text.
588        assert_eq!(caret_visual_line(&ranges, 12), (2, 10));
589    }
590
591    #[test]
592    fn caret_visual_line_handles_empty_ranges() {
593        assert_eq!(caret_visual_line(&[], 5), (0, 0));
594    }
595
596    #[test]
597    fn handle_drag_keeps_edges_from_crossing() {
598        // Dragging the end handle left past the start clamps to start+1.
599        assert_eq!(
600            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
601            (5, 6)
602        );
603        // Dragging the end handle right extends normally.
604        assert_eq!(
605            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
606            (5, 12)
607        );
608        // Dragging the start handle right past the end clamps to end-1.
609        assert_eq!(
610            selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
611            (7, 8)
612        );
613        // Dragging the start handle left extends normally.
614        assert_eq!(
615            selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
616            (3, 8)
617        );
618        // The cursor handle moves a collapsed caret.
619        assert_eq!(
620            selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
621            (9, 9)
622        );
623    }
624}