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            previous_count.max(1).saturating_add(1)
106        } else {
107            2
108        }
109    } else {
110        raw_tap_count
111    }
112}
113
114/// Maps a 1-based tap count to the granularity it selects.
115///
116/// A single tap places the caret; two taps select the word, three the line,
117/// four the paragraph, and every further tap cycles back through
118/// word → line → paragraph so a resting finger keeps toggling between the three
119/// range granularities (matching desktop editors and iOS).
120pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
121    match tap_count {
122        0 | 1 => SelectionGranularity::Caret,
123        n => match (n - 2) % 3 {
124            0 => SelectionGranularity::Word,
125            1 => SelectionGranularity::Line,
126            _ => SelectionGranularity::Paragraph,
127        },
128    }
129}
130
131/// Returns the byte range `[start, end)` of the line containing `pos`, delimited
132/// by `\n` (the newline itself is excluded from the range).
133///
134/// Used for triple-tap line selection. Byte offsets always land on `char`
135/// boundaries because `\n` is a single-byte ASCII character.
136pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
137    let pos = pos.min(text.len());
138    let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
139    let end = text[pos..]
140        .find('\n')
141        .map(|i| pos + i)
142        .unwrap_or(text.len());
143    (start, end)
144}
145
146/// Returns the byte range `[start, end)` of the paragraph containing `pos`.
147///
148/// Paragraphs are delimited by blank lines — a run of two or more consecutive
149/// `\n` — so a fourth tap grows the selection from one line to the whole block
150/// of text around it. Text with no blank line is a single paragraph (the whole
151/// string). Byte offsets land on `char` boundaries because `\n` is single-byte
152/// ASCII. Unicode-aware: multi-byte characters inside the paragraph are spanned
153/// whole.
154pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
155    let pos = pos.min(text.len());
156    let start = text[..pos]
157        .rfind("\n\n")
158        .map(|i| {
159            let mut s = i + 1;
160            while text[s..].starts_with('\n') {
161                s += 1;
162            }
163            s
164        })
165        .unwrap_or(0);
166    let end = text[pos..]
167        .find("\n\n")
168        .map(|i| pos + i)
169        .unwrap_or(text.len());
170    (start.min(end), end)
171}
172
173/// Which visual line a caret/handle at a soft-wrap boundary belongs to. At a
174/// shared boundary byte (the end of one wrapped visual line IS the start of
175/// the next — mid-word wraps produce these) the offset alone is ambiguous:
176///
177/// * [`LineAffinity::Upstream`] anchors to the END of the upper line — the
178///   glyph a dragging finger means. Selection END and cursor handles, the
179///   drawn caret, and the loupe use this; without it a drag along a wrapped
180///   line's right edge snaps the handle one line DOWN and to the left edge.
181/// * [`LineAffinity::Downstream`] anchors to the START of the lower line —
182///   where the first selected glyph actually renders. Selection START handles
183///   and highlight geometry use this.
184#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub enum LineAffinity {
186    Upstream,
187    Downstream,
188}
189
190/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
191/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
192/// sits on.
193///
194/// The caret belongs to the last visual line whose start is at or before
195/// `offset`, except at a shared soft-wrap boundary where `affinity` decides
196/// (see [`LineAffinity`]):
197/// * a caret in the middle of a visual line resolves to that line;
198/// * a caret at the very end of the text sits on the last visual line.
199///
200/// This is the wrap-aware replacement for counting logical `\n` lines: without
201/// it, a caret on a wrapped line's second visual line is drawn on the first (and
202/// its x runs off the right edge), even though typing and the magnifier place it
203/// correctly. Returns `(0, 0)` when there are no ranges.
204pub fn caret_visual_line(
205    ranges: &[std::ops::Range<usize>],
206    offset: usize,
207    affinity: LineAffinity,
208) -> (usize, usize) {
209    let mut result = (0usize, 0usize);
210    for (index, range) in ranges.iter().enumerate() {
211        if range.start <= offset {
212            if affinity == LineAffinity::Upstream
213                && index > 0
214                && range.start == offset
215                && ranges[index - 1].end == offset
216                && ranges[index - 1].start < offset
217            {
218                break;
219            }
220            result = (index, range.start);
221        } else {
222            break;
223        }
224    }
225    result
226}
227
228/// Downward travel that follows with the original finger-to-handle offset
229/// before the visibility drift starts.
230pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
231/// Additional downward travel over which the handle moves into full view.
232pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
233/// Extra clearance (dp) below the handle dot once fully visible above the
234/// finger.
235pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
236
237/// The drift target: bias placing the finger just below the handle dot
238/// (tip + dot + clearance), so the whole lollipop stays visible above it.
239pub fn grab_bias_full_view() -> f32 {
240    -(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
241}
242
243/// Finger-to-handle relationship for one drag. The first phase preserves the
244/// captured offset exactly, the second shifts the handle above the finger,
245/// and the third preserves that final offset exactly. Progress is based on
246/// the furthest displacement from the grab, so event cadence and small
247/// reversals cannot change the result.
248#[derive(Clone, Copy, Debug, PartialEq)]
249pub struct HandleGrabOffset {
250    initial_bias: f32,
251    bias: f32,
252    start_y: f32,
253    furthest_y: f32,
254    drift_progress: f32,
255    drifts: bool,
256}
257
258impl HandleGrabOffset {
259    pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
260        Self::begin_for(handle_tip_y, finger_y, true)
261    }
262
263    pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
264        let initial_bias = handle_tip_y - finger_y;
265        Self {
266            initial_bias,
267            bias: initial_bias,
268            start_y: finger_y,
269            furthest_y: finger_y,
270            drift_progress: 0.0,
271            drifts,
272        }
273    }
274
275    pub fn track(&mut self, finger_y: f32) -> f32 {
276        if !self.drifts {
277            self.bias = self.initial_bias;
278            return self.bias;
279        }
280        self.furthest_y = self.furthest_y.max(finger_y);
281        let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
282        let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
283        self.drift_progress = t * t * (3.0 - 2.0 * t);
284        let full_view = self.initial_bias.min(grab_bias_full_view());
285        self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
286        self.bias
287    }
288
289    pub fn bias(&self) -> f32 {
290        self.bias
291    }
292
293    pub fn drift_progress(&self) -> f32 {
294        self.drift_progress
295    }
296}
297
298/// Which selection handle a lollipop represents.
299#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
300pub enum HandleKind {
301    /// The cursor handle shown for a collapsed selection: the caret stem with a
302    /// round grab dot hanging below the line (like the end handle).
303    Cursor,
304    /// The start (leftmost) selection handle: dot ON TOP of the line, stem
305    /// spanning the line box below it.
306    SelectionStart,
307    /// The end (rightmost) selection handle: stem spanning the line box, dot
308    /// hanging BELOW it.
309    SelectionEnd,
310}
311
312/// Radius of a selection/cursor handle dot in dp (the reference dot is
313/// 16.2 physical px at 3x ≈ a 16 dp circle).
314pub const HANDLE_RADIUS: f32 = 8.0;
315
316/// Width of the handle stem in dp (measured 6 px at 3x = 2 dp — the same
317/// weight as the caret).
318pub const HANDLE_STEM_WIDTH: f32 = 2.0;
319
320/// How far the dot dips INTO the line box (dp): the reference start dot's
321/// bottom sits ~5 px (1.7 dp) below the line-box top, the end dot's top ~6 px
322/// above the line-box bottom, so dot and stem read as one continuous shape.
323pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
324
325/// SVG path data for a handle lollipop at a text edge.
326///
327/// `anchor_x` is the text edge (caret / selection endpoint) x; the line box
328/// spans `line_top .. line_bottom`. The stem (width
329/// [`HANDLE_STEM_WIDTH`]) always spans the line box, centered on `anchor_x`;
330/// the dot (radius `radius`) sits tangent just outside the line box — above it
331/// for [`SelectionStart`](HandleKind::SelectionStart), below it for
332/// [`SelectionEnd`](HandleKind::SelectionEnd) and
333/// [`Cursor`](HandleKind::Cursor) — overlapping the box edge by
334/// [`HANDLE_DOT_LINE_OVERLAP`] so the two read as one shape.
335pub fn handle_path_data(
336    kind: HandleKind,
337    anchor_x: f32,
338    line_top: f32,
339    line_bottom: f32,
340    radius: f32,
341) -> String {
342    let r = radius.max(0.0);
343    let half_stem = HANDLE_STEM_WIDTH * 0.5;
344    let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
345    let stem = |top: f32, bottom: f32| {
346        format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
347    };
348    let dot = |cy: f32| {
349        format!(
350            "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
351            x0 = anchor_x - r,
352            x1 = anchor_x + r,
353        )
354    };
355    match kind {
356        HandleKind::SelectionStart => {
357            let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
358            format!("{} {}", stem(line_top, line_bottom), dot(cy))
359        }
360        HandleKind::SelectionEnd | HandleKind::Cursor => {
361            let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
362            format!("{} {}", stem(line_top, line_bottom), dot(cy))
363        }
364    }
365}
366
367/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
368/// its touch target, matching Android's generous handle hit area. A bare
369/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
370/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
371/// slop the press falls through to the field below and places a caret, which
372/// collapses the selection. The slop is applied to the sides and BELOW the tip
373/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
374/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
375/// so a double-tap still reaches the field to escalate into a word selection.
376pub const HANDLE_GRAB_SLOP: f32 = 24.0;
377
378/// Computes the selection `(min, max)` that results from dragging one handle to
379/// a new text `offset`, keeping the opposite (fixed) edge anchored.
380///
381/// Dragging never lets the two edges cross: a dragged start clamps to just
382/// before the fixed end, and a dragged end clamps to just after the fixed
383/// start, so the selection keeps at least one selected unit.
384pub fn selection_after_handle_drag(
385    dragged: HandleKind,
386    fixed_edge: usize,
387    dragged_offset: usize,
388    text_len: usize,
389) -> (usize, usize) {
390    let fixed = fixed_edge.min(text_len);
391    let dragged_offset = dragged_offset.min(text_len);
392    match dragged {
393        HandleKind::SelectionStart => {
394            let start = dragged_offset.min(fixed.saturating_sub(1));
395            (start, fixed)
396        }
397        HandleKind::SelectionEnd => {
398            let end = dragged_offset.max(fixed + 1).min(text_len);
399            (fixed, end)
400        }
401        HandleKind::Cursor => (dragged_offset, dragged_offset),
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn tap_classification_escalates_within_time_and_slop() {
411        assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
412        assert_eq!(
413            classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
414            2
415        );
416        assert_eq!(
417            classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
418            3
419        );
420        assert_eq!(
421            classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
422            4
423        );
424        assert_eq!(
425            classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
426            5
427        );
428    }
429
430    #[test]
431    fn tap_classification_resets_past_timeout_or_slop() {
432        assert_eq!(
433            classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
434            1
435        );
436        assert_eq!(
437            classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
438            1
439        );
440        assert_eq!(
441            classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
442            1
443        );
444    }
445
446    #[test]
447    fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
448        use SelectionGranularity::*;
449
450        let mut count = resolve_selection_tap_count(1, 0, true, false);
451        assert_eq!(count, 2);
452        assert_eq!(tap_selection_granularity(count), Word);
453
454        count = resolve_selection_tap_count(1, count, true, true);
455        assert_eq!(count, 3);
456        assert_eq!(tap_selection_granularity(count), Line);
457
458        count = resolve_selection_tap_count(1, count, true, true);
459        assert_eq!(count, 4);
460        assert_eq!(tap_selection_granularity(count), Paragraph);
461
462        count = resolve_selection_tap_count(1, count, true, true);
463        assert_eq!(count, 5);
464        assert_eq!(tap_selection_granularity(count), Word);
465
466        let reset = resolve_selection_tap_count(1, count, true, false);
467        assert_eq!(reset, 2);
468        assert_eq!(tap_selection_granularity(reset), Word);
469    }
470
471    #[test]
472    fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
473        assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
474        assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
475        assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
476    }
477
478    #[test]
479    fn tap_granularity_grows_then_cycles() {
480        use SelectionGranularity::*;
481        assert_eq!(tap_selection_granularity(0), Caret);
482        assert_eq!(tap_selection_granularity(1), Caret);
483        assert_eq!(tap_selection_granularity(2), Word);
484        assert_eq!(tap_selection_granularity(3), Line);
485        assert_eq!(tap_selection_granularity(4), Paragraph);
486        assert_eq!(tap_selection_granularity(5), Word);
487        assert_eq!(tap_selection_granularity(6), Line);
488        assert_eq!(tap_selection_granularity(7), Paragraph);
489        assert_eq!(tap_selection_granularity(8), Word);
490    }
491
492    #[test]
493    fn paragraph_boundaries_span_blank_line_delimited_blocks() {
494        let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
495        let (s, e) = find_paragraph_boundaries(text, 3);
496        assert_eq!(&text[s..e], "line one\nline two");
497        let (s, e) = find_paragraph_boundaries(text, 20);
498        assert_eq!(&text[s..e], "second para\nstill second");
499        let (s, e) = find_paragraph_boundaries(text, text.len());
500        assert_eq!(&text[s..e], "third");
501    }
502
503    #[test]
504    fn paragraph_boundaries_no_blank_line_is_whole_text() {
505        let text = "just\none\nblock";
506        assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
507    }
508
509    #[test]
510    fn paragraph_boundaries_are_unicode_aware() {
511        let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
512        let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
513        let (s, e) = find_paragraph_boundaries(text, 3);
514        assert_eq!(&text[s..e], first);
515        assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
516    }
517
518    #[test]
519    fn line_boundaries_span_between_newlines() {
520        let text = "first line\nsecond line\nthird";
521        assert_eq!(find_line_boundaries(text, 15), (11, 22));
522        assert_eq!(find_line_boundaries(text, 0), (0, 10));
523        assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
524    }
525
526    #[test]
527    fn line_boundaries_handle_unicode_and_empty_lines() {
528        let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
529        let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
530        assert_eq!(start, end);
531        let last = find_line_boundaries(text, text.len());
532        assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
533    }
534
535    #[test]
536    fn handle_path_is_valid_and_spans_the_line_box() {
537        let (x, top, bottom) = (40.0_f32, 20.0_f32, 40.0_f32);
538        for kind in [
539            HandleKind::Cursor,
540            HandleKind::SelectionStart,
541            HandleKind::SelectionEnd,
542        ] {
543            let data = handle_path_data(kind, x, top, bottom, HANDLE_RADIUS);
544            let path = cranpose_ui_graphics::VectorPath::parse(&data)
545                .expect("handle path must be valid SVG");
546            assert!(!path.is_empty(), "{kind:?} handle must have geometry");
547            let bounds = path.bounds();
548            assert!(bounds.y <= top + 0.5, "{kind:?} must reach the line top");
549            assert!(
550                bounds.y + bounds.height >= bottom - 0.5,
551                "{kind:?} must reach the line bottom"
552            );
553            assert!((bounds.x - (x - HANDLE_RADIUS)).abs() <= 0.5);
554            assert!((bounds.x + bounds.width - (x + HANDLE_RADIUS)).abs() <= 0.5);
555        }
556    }
557
558    #[test]
559    fn selection_handle_dots_sit_on_the_correct_side_of_the_line() {
560        let (x, top, bottom, r) = (40.0_f32, 20.0_f32, 40.0_f32, HANDLE_RADIUS);
561        let eps = 0.5_f32;
562
563        let bounds = |kind: HandleKind| {
564            let data = handle_path_data(kind, x, top, bottom, r);
565            cranpose_ui_graphics::VectorPath::parse(&data)
566                .expect("valid handle path")
567                .bounds()
568        };
569
570        let start = bounds(HandleKind::SelectionStart);
571        assert!(
572            (start.y - (top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
573            "start dot must ride on top of the line (top at {}, expected {})",
574            start.y,
575            top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP
576        );
577        assert!(
578            start.y + start.height <= bottom + eps,
579            "start handle must not extend below the line box"
580        );
581
582        for kind in [HandleKind::SelectionEnd, HandleKind::Cursor] {
583            let b = bounds(kind);
584            assert!(
585                (b.y + b.height - (bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
586                "{kind:?} dot must hang below the line (bottom at {}, expected {})",
587                b.y + b.height,
588                bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP
589            );
590            assert!(
591                b.y >= top - eps,
592                "{kind:?} handle must not extend above the line box"
593            );
594        }
595    }
596
597    #[test]
598    fn caret_visual_line_resolves_wrapped_visual_lines() {
599        let ranges = vec![0..5usize, 5..9, 10..12];
600
601        assert_eq!(
602            caret_visual_line(&ranges, 0, LineAffinity::Downstream),
603            (0, 0)
604        );
605        assert_eq!(
606            caret_visual_line(&ranges, 3, LineAffinity::Downstream),
607            (0, 0)
608        );
609        assert_eq!(
610            caret_visual_line(&ranges, 5, LineAffinity::Downstream),
611            (1, 5)
612        );
613        assert_eq!(
614            caret_visual_line(&ranges, 7, LineAffinity::Downstream),
615            (1, 5)
616        );
617        assert_eq!(
618            caret_visual_line(&ranges, 9, LineAffinity::Downstream),
619            (1, 5)
620        );
621        assert_eq!(
622            caret_visual_line(&ranges, 11, LineAffinity::Downstream),
623            (2, 10)
624        );
625        assert_eq!(
626            caret_visual_line(&ranges, 12, LineAffinity::Downstream),
627            (2, 10)
628        );
629    }
630
631    #[test]
632    fn start_handle_grab_never_drifts() {
633        let mut grab = HandleGrabOffset::begin_for(108.0, 100.0, false);
634        assert_eq!(grab.track(108.0), 8.0);
635        assert_eq!(grab.track(160.0), 8.0, "no drift on long downward travel");
636        assert_eq!(grab.drift_progress(), 0.0);
637    }
638
639    #[test]
640    fn grab_offset_has_follow_drift_and_strict_phases() {
641        let mut grab = HandleGrabOffset::begin(108.0, 100.0);
642        assert_eq!(grab.bias(), 8.0);
643
644        let direct_bias = grab.track(108.0);
645        assert_eq!(direct_bias, 8.0, "initial travel follows exactly");
646        assert_eq!(108.0 + direct_bias, 116.0);
647
648        let drifting_bias = grab.track(132.0);
649        assert!(drifting_bias < 8.0 && drifting_bias > grab_bias_full_view());
650        assert!((0.0..1.0).contains(&grab.drift_progress()));
651
652        assert_eq!(grab.track(156.0), grab_bias_full_view());
653        assert_eq!(grab.drift_progress(), 1.0);
654        assert_eq!(grab.track(220.0), grab_bias_full_view());
655    }
656
657    #[test]
658    fn grab_offset_is_cadence_independent_and_never_unwinds() {
659        let mut single = HandleGrabOffset::begin(108.0, 100.0);
660        single.track(140.0);
661
662        let mut sampled = HandleGrabOffset::begin(108.0, 100.0);
663        for y in [104.0, 109.0, 116.0, 130.0, 140.0] {
664            sampled.track(y);
665        }
666        assert_eq!(sampled.bias(), single.bias());
667        assert_eq!(sampled.drift_progress(), single.drift_progress());
668
669        let migrated = sampled.bias();
670        sampled.track(90.0);
671        assert_eq!(
672            sampled.bias(),
673            migrated,
674            "upward travel cannot unwind drift"
675        );
676
677        let deep = grab_bias_full_view() - 10.0;
678        let mut already_visible = HandleGrabOffset::begin(deep, 0.0);
679        already_visible.track(100.0);
680        assert_eq!(already_visible.bias(), deep);
681    }
682
683    #[test]
684    fn caret_visual_line_handles_empty_ranges() {
685        assert_eq!(caret_visual_line(&[], 5, LineAffinity::Upstream), (0, 0));
686        assert_eq!(caret_visual_line(&[], 5, LineAffinity::Downstream), (0, 0));
687    }
688
689    #[test]
690    fn caret_visual_line_upstream_anchors_shared_wrap_boundary_to_upper_line() {
691        let ranges = vec![0..5usize, 5..9, 10..12];
692
693        assert_eq!(
694            caret_visual_line(&ranges, 5, LineAffinity::Upstream),
695            (0, 0)
696        );
697        assert_eq!(
698            caret_visual_line(&ranges, 5, LineAffinity::Downstream),
699            (1, 5)
700        );
701
702        assert_eq!(
703            caret_visual_line(&ranges, 3, LineAffinity::Upstream),
704            (0, 0)
705        );
706        assert_eq!(
707            caret_visual_line(&ranges, 7, LineAffinity::Upstream),
708            (1, 5)
709        );
710
711        assert_eq!(
712            caret_visual_line(&ranges, 10, LineAffinity::Upstream),
713            (2, 10)
714        );
715
716        assert_eq!(
717            caret_visual_line(&ranges, 12, LineAffinity::Upstream),
718            (2, 10)
719        );
720    }
721
722    #[test]
723    fn handle_drag_keeps_edges_from_crossing() {
724        assert_eq!(
725            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
726            (5, 6)
727        );
728        assert_eq!(
729            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
730            (5, 12)
731        );
732        assert_eq!(
733            selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
734            (7, 8)
735        );
736        assert_eq!(
737            selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
738            (3, 8)
739        );
740        assert_eq!(
741            selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
742            (9, 9)
743        );
744    }
745}