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/// Which visual line a caret/handle at a soft-wrap boundary belongs to. At a
181/// shared boundary byte (the end of one wrapped visual line IS the start of
182/// the next — mid-word wraps produce these) the offset alone is ambiguous:
183///
184/// * [`LineAffinity::Upstream`] anchors to the END of the upper line — the
185///   glyph a dragging finger means. Selection END and cursor handles, the
186///   drawn caret, and the loupe use this; without it a drag along a wrapped
187///   line's right edge snaps the handle one line DOWN and to the left edge.
188/// * [`LineAffinity::Downstream`] anchors to the START of the lower line —
189///   where the first selected glyph actually renders. Selection START handles
190///   and highlight geometry use this.
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum LineAffinity {
193    Upstream,
194    Downstream,
195}
196
197/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
198/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
199/// sits on.
200///
201/// The caret belongs to the last visual line whose start is at or before
202/// `offset`, except at a shared soft-wrap boundary where `affinity` decides
203/// (see [`LineAffinity`]):
204/// * a caret in the middle of a visual line resolves to that line;
205/// * a caret at the very end of the text sits on the last visual line.
206///
207/// This is the wrap-aware replacement for counting logical `\n` lines: without
208/// it, a caret on a wrapped line's second visual line is drawn on the first (and
209/// its x runs off the right edge), even though typing and the magnifier place it
210/// correctly. Returns `(0, 0)` when there are no ranges.
211pub fn caret_visual_line(
212    ranges: &[std::ops::Range<usize>],
213    offset: usize,
214    affinity: LineAffinity,
215) -> (usize, usize) {
216    let mut result = (0usize, 0usize);
217    for (index, range) in ranges.iter().enumerate() {
218        if range.start <= offset {
219            // A SHARED boundary (the previous line ends exactly where this one
220            // starts — soft wrap, no separator byte) belongs upstream to the
221            // upper line's end. A hard `\n` never shares (the ranges gap over
222            // the separator), and an empty upper line never captures.
223            if affinity == LineAffinity::Upstream
224                && index > 0
225                && range.start == offset
226                && ranges[index - 1].end == offset
227                && ranges[index - 1].start < offset
228            {
229                break;
230            }
231            result = (index, range.start);
232        } else {
233            break;
234        }
235    }
236    result
237}
238
239/// Downward travel that follows with the original finger-to-handle offset
240/// before the visibility drift starts.
241pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
242/// Additional downward travel over which the handle moves into full view.
243pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
244/// Extra clearance (dp) below the handle dot once fully visible above the
245/// finger.
246pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
247
248/// The drift target: bias placing the finger just below the handle dot
249/// (tip + dot + clearance), so the whole lollipop stays visible above it.
250pub fn grab_bias_full_view() -> f32 {
251    -(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
252}
253
254/// Finger-to-handle relationship for one drag. The first phase preserves the
255/// captured offset exactly, the second shifts the handle above the finger,
256/// and the third preserves that final offset exactly. Progress is based on
257/// the furthest displacement from the grab, so event cadence and small
258/// reversals cannot change the result.
259#[derive(Clone, Copy, Debug, PartialEq)]
260pub struct HandleGrabOffset {
261    initial_bias: f32,
262    bias: f32,
263    start_y: f32,
264    furthest_y: f32,
265    drift_progress: f32,
266    /// The visibility drift only applies to handles whose dot hangs BELOW
267    /// the line (end/cursor): there the finger covers the content and the
268    /// handle floats clear of it. The start handle's dot rides ABOVE the
269    /// line — nothing is covered, so its drag follows the finger directly
270    /// (user-directed).
271    drifts: bool,
272}
273
274impl HandleGrabOffset {
275    pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
276        Self::begin_for(handle_tip_y, finger_y, true)
277    }
278
279    pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
280        let initial_bias = handle_tip_y - finger_y;
281        Self {
282            initial_bias,
283            bias: initial_bias,
284            start_y: finger_y,
285            furthest_y: finger_y,
286            drift_progress: 0.0,
287            drifts,
288        }
289    }
290
291    pub fn track(&mut self, finger_y: f32) -> f32 {
292        if !self.drifts {
293            self.bias = self.initial_bias;
294            return self.bias;
295        }
296        self.furthest_y = self.furthest_y.max(finger_y);
297        let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
298        let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
299        self.drift_progress = t * t * (3.0 - 2.0 * t);
300        let full_view = self.initial_bias.min(grab_bias_full_view());
301        self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
302        self.bias
303    }
304
305    pub fn bias(&self) -> f32 {
306        self.bias
307    }
308
309    pub fn drift_progress(&self) -> f32 {
310        self.drift_progress
311    }
312}
313
314/// Which selection handle a lollipop represents.
315#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
316pub enum HandleKind {
317    /// The cursor handle shown for a collapsed selection: the caret stem with a
318    /// round grab dot hanging below the line (like the end handle).
319    Cursor,
320    /// The start (leftmost) selection handle: dot ON TOP of the line, stem
321    /// spanning the line box below it.
322    SelectionStart,
323    /// The end (rightmost) selection handle: stem spanning the line box, dot
324    /// hanging BELOW it.
325    SelectionEnd,
326}
327
328/// Radius of a selection/cursor handle dot in dp (the reference dot is
329/// 16.2 physical px at 3x ≈ a 16 dp circle).
330pub const HANDLE_RADIUS: f32 = 8.0;
331
332/// Width of the handle stem in dp (measured 6 px at 3x = 2 dp — the same
333/// weight as the caret).
334pub const HANDLE_STEM_WIDTH: f32 = 2.0;
335
336/// How far the dot dips INTO the line box (dp): the reference start dot's
337/// bottom sits ~5 px (1.7 dp) below the line-box top, the end dot's top ~6 px
338/// above the line-box bottom, so dot and stem read as one continuous shape.
339pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
340
341/// SVG path data for a handle lollipop at a text edge.
342///
343/// `anchor_x` is the text edge (caret / selection endpoint) x; the line box
344/// spans `line_top .. line_bottom`. The stem (width
345/// [`HANDLE_STEM_WIDTH`]) always spans the line box, centered on `anchor_x`;
346/// the dot (radius `radius`) sits tangent just outside the line box — above it
347/// for [`SelectionStart`](HandleKind::SelectionStart), below it for
348/// [`SelectionEnd`](HandleKind::SelectionEnd) and
349/// [`Cursor`](HandleKind::Cursor) — overlapping the box edge by
350/// [`HANDLE_DOT_LINE_OVERLAP`] so the two read as one shape.
351pub fn handle_path_data(
352    kind: HandleKind,
353    anchor_x: f32,
354    line_top: f32,
355    line_bottom: f32,
356    radius: f32,
357) -> String {
358    let r = radius.max(0.0);
359    let half_stem = HANDLE_STEM_WIDTH * 0.5;
360    let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
361    let stem = |top: f32, bottom: f32| {
362        format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
363    };
364    let dot = |cy: f32| {
365        // Sweep flag 1 keeps the circle CLOCKWISE like the stem rectangle:
366        // with the NonZero fill rule, same-direction subpaths union; opposite
367        // windings cancel where dot and stem overlap, punching a notch at
368        // the joint.
369        format!(
370            "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
371            x0 = anchor_x - r,
372            x1 = anchor_x + r,
373        )
374    };
375    match kind {
376        HandleKind::SelectionStart => {
377            // Dot on top: center a radius above the line top, minus the overlap.
378            let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
379            format!("{} {}", stem(line_top, line_bottom), dot(cy))
380        }
381        HandleKind::SelectionEnd | HandleKind::Cursor => {
382            // Dot below: center a radius under the line bottom, minus overlap.
383            let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
384            format!("{} {}", stem(line_top, line_bottom), dot(cy))
385        }
386    }
387}
388
389/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
390/// its touch target, matching Android's generous handle hit area. A bare
391/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
392/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
393/// slop the press falls through to the field below and places a caret, which
394/// collapses the selection. The slop is applied to the sides and BELOW the tip
395/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
396/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
397/// so a double-tap still reaches the field to escalate into a word selection.
398pub const HANDLE_GRAB_SLOP: f32 = 24.0;
399
400/// Computes the selection `(min, max)` that results from dragging one handle to
401/// a new text `offset`, keeping the opposite (fixed) edge anchored.
402///
403/// Dragging never lets the two edges cross: a dragged start clamps to just
404/// before the fixed end, and a dragged end clamps to just after the fixed
405/// start, so the selection keeps at least one selected unit.
406pub fn selection_after_handle_drag(
407    dragged: HandleKind,
408    fixed_edge: usize,
409    dragged_offset: usize,
410    text_len: usize,
411) -> (usize, usize) {
412    let fixed = fixed_edge.min(text_len);
413    let dragged_offset = dragged_offset.min(text_len);
414    match dragged {
415        HandleKind::SelectionStart => {
416            let start = dragged_offset.min(fixed.saturating_sub(1));
417            (start, fixed)
418        }
419        HandleKind::SelectionEnd => {
420            let end = dragged_offset.max(fixed + 1).min(text_len);
421            (fixed, end)
422        }
423        // The cursor handle just moves the collapsed caret.
424        HandleKind::Cursor => (dragged_offset, dragged_offset),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn tap_classification_escalates_within_time_and_slop() {
434        assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
435        assert_eq!(
436            classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
437            2
438        );
439        assert_eq!(
440            classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
441            3
442        );
443        // A fourth in-place tap keeps counting up (the granularity mapping is
444        // what cycles, not the raw count).
445        assert_eq!(
446            classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
447            4
448        );
449        assert_eq!(
450            classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
451            5
452        );
453    }
454
455    #[test]
456    fn tap_classification_resets_past_timeout_or_slop() {
457        // Too slow: restarts.
458        assert_eq!(
459            classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
460            1
461        );
462        // Too far: restarts even though it is quick.
463        assert_eq!(
464            classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
465            1
466        );
467        // A reset also applies from a higher count.
468        assert_eq!(
469            classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
470            1
471        );
472    }
473
474    /// The tap-inside-selection ladder (bug c): a lone tap inside an existing
475    /// selection grabs the word, and every further tap AT THE SAME SPOT grows
476    /// the granularity word → line → paragraph, then cycles back to word — even
477    /// when the taps arrive too slowly to count as a rapid multi-tap (the growth
478    /// is keyed on location, not the double-tap timeout). Tapping a NEW spot
479    /// resets to word.
480    #[test]
481    fn tap_inside_selection_cycles_word_line_paragraph_by_location() {
482        use SelectionGranularity::*;
483
484        // Start: a lone (slow) tap inside a selection. raw_tap_count == 1
485        // (the timeout lapsed), but it still grabs the word.
486        let mut count = resolve_selection_tap_count(1, 0, true, false);
487        assert_eq!(count, 2);
488        assert_eq!(tap_selection_granularity(count), Word);
489
490        // Same spot again, still slow (raw == 1): grow to the line.
491        count = resolve_selection_tap_count(1, count, true, true);
492        assert_eq!(count, 3);
493        assert_eq!(tap_selection_granularity(count), Line);
494
495        // Same spot again: grow to the paragraph.
496        count = resolve_selection_tap_count(1, count, true, true);
497        assert_eq!(count, 4);
498        assert_eq!(tap_selection_granularity(count), Paragraph);
499
500        // Same spot again: cycle back to the word.
501        count = resolve_selection_tap_count(1, count, true, true);
502        assert_eq!(count, 5);
503        assert_eq!(tap_selection_granularity(count), Word);
504
505        // A tap at a NEW spot inside the selection resets to word.
506        let reset = resolve_selection_tap_count(1, count, true, false);
507        assert_eq!(reset, 2);
508        assert_eq!(tap_selection_granularity(reset), Word);
509    }
510
511    /// A genuine rapid multi-tap keeps using its own running count, so
512    /// [`resolve_selection_tap_count`] does not disturb the double→word,
513    /// triple→line ladder, and a lone tap outside a selection stays a caret.
514    #[test]
515    fn resolve_tap_count_preserves_rapid_multitap_and_caret() {
516        // Rapid multi-tap: pass the classify count straight through.
517        assert_eq!(resolve_selection_tap_count(2, 1, false, false), 2);
518        assert_eq!(resolve_selection_tap_count(3, 2, true, true), 3);
519        // Lone tap outside any selection: caret (count 1).
520        assert_eq!(resolve_selection_tap_count(1, 4, false, true), 1);
521    }
522
523    #[test]
524    fn tap_granularity_grows_then_cycles() {
525        use SelectionGranularity::*;
526        assert_eq!(tap_selection_granularity(0), Caret);
527        assert_eq!(tap_selection_granularity(1), Caret);
528        assert_eq!(tap_selection_granularity(2), Word);
529        assert_eq!(tap_selection_granularity(3), Line);
530        assert_eq!(tap_selection_granularity(4), Paragraph);
531        // Fifth tap cycles back to word, then line, then paragraph again.
532        assert_eq!(tap_selection_granularity(5), Word);
533        assert_eq!(tap_selection_granularity(6), Line);
534        assert_eq!(tap_selection_granularity(7), Paragraph);
535        assert_eq!(tap_selection_granularity(8), Word);
536    }
537
538    #[test]
539    fn paragraph_boundaries_span_blank_line_delimited_blocks() {
540        let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
541        // Inside the first paragraph (two lines).
542        let (s, e) = find_paragraph_boundaries(text, 3);
543        assert_eq!(&text[s..e], "line one\nline two");
544        // Inside the second paragraph.
545        let (s, e) = find_paragraph_boundaries(text, 20);
546        assert_eq!(&text[s..e], "second para\nstill second");
547        // Inside the third paragraph, after a run of THREE newlines.
548        let (s, e) = find_paragraph_boundaries(text, text.len());
549        assert_eq!(&text[s..e], "third");
550    }
551
552    #[test]
553    fn paragraph_boundaries_no_blank_line_is_whole_text() {
554        let text = "just\none\nblock";
555        assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
556    }
557
558    #[test]
559    fn paragraph_boundaries_are_unicode_aware() {
560        // Multi-byte characters must be spanned whole and offsets stay on char
561        // boundaries.
562        let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
563        let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
564        let (s, e) = find_paragraph_boundaries(text, 3);
565        assert_eq!(&text[s..e], first);
566        assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
567    }
568
569    #[test]
570    fn line_boundaries_span_between_newlines() {
571        let text = "first line\nsecond line\nthird";
572        // Inside the second line.
573        assert_eq!(find_line_boundaries(text, 15), (11, 22));
574        // Start of the first line.
575        assert_eq!(find_line_boundaries(text, 0), (0, 10));
576        // Inside the last (newline-terminated-absent) line.
577        assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
578    }
579
580    #[test]
581    fn line_boundaries_handle_unicode_and_empty_lines() {
582        let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
583        // Empty middle line: start == end at the byte after the first newline.
584        let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
585        assert_eq!(start, end);
586        // Last line spans the two CJK characters.
587        let last = find_line_boundaries(text, text.len());
588        assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
589    }
590
591    #[test]
592    fn handle_path_is_valid_and_spans_the_line_box() {
593        let (x, top, bottom) = (40.0_f32, 20.0_f32, 40.0_f32);
594        for kind in [
595            HandleKind::Cursor,
596            HandleKind::SelectionStart,
597            HandleKind::SelectionEnd,
598        ] {
599            let data = handle_path_data(kind, x, top, bottom, HANDLE_RADIUS);
600            let path = cranpose_ui_graphics::VectorPath::parse(&data)
601                .expect("handle path must be valid SVG");
602            assert!(!path.is_empty(), "{kind:?} handle must have geometry");
603            let bounds = path.bounds();
604            // The stem spans the line box, so the shape covers top..bottom.
605            assert!(bounds.y <= top + 0.5, "{kind:?} must reach the line top");
606            assert!(
607                bounds.y + bounds.height >= bottom - 0.5,
608                "{kind:?} must reach the line bottom"
609            );
610            // Horizontally centered on the anchor, a dot-radius each way.
611            assert!((bounds.x - (x - HANDLE_RADIUS)).abs() <= 0.5);
612            assert!((bounds.x + bounds.width - (x + HANDLE_RADIUS)).abs() <= 0.5);
613        }
614    }
615
616    /// The reference lollipop orientation: the start handle's dot rides ON TOP
617    /// of the line (center ~a radius above the line top), the end and cursor
618    /// dots hang BELOW it, and every dot dips [`HANDLE_DOT_LINE_OVERLAP`] into
619    /// the line box so dot + stem read as one continuous shape.
620    #[test]
621    fn selection_handle_dots_sit_on_the_correct_side_of_the_line() {
622        let (x, top, bottom, r) = (40.0_f32, 20.0_f32, 40.0_f32, HANDLE_RADIUS);
623        let eps = 0.5_f32;
624
625        let bounds = |kind: HandleKind| {
626            let data = handle_path_data(kind, x, top, bottom, r);
627            cranpose_ui_graphics::VectorPath::parse(&data)
628                .expect("valid handle path")
629                .bounds()
630        };
631
632        // Start: the shape extends a dot-diameter ABOVE the line top (minus the
633        // overlap), and not below the line bottom.
634        let start = bounds(HandleKind::SelectionStart);
635        assert!(
636            (start.y - (top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
637            "start dot must ride on top of the line (top at {}, expected {})",
638            start.y,
639            top - 2.0 * r + HANDLE_DOT_LINE_OVERLAP
640        );
641        assert!(
642            start.y + start.height <= bottom + eps,
643            "start handle must not extend below the line box"
644        );
645
646        // End and cursor: the shape extends a dot-diameter BELOW the line
647        // bottom (minus the overlap), and not above the line top.
648        for kind in [HandleKind::SelectionEnd, HandleKind::Cursor] {
649            let b = bounds(kind);
650            assert!(
651                (b.y + b.height - (bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP)).abs() <= eps,
652                "{kind:?} dot must hang below the line (bottom at {}, expected {})",
653                b.y + b.height,
654                bottom + 2.0 * r - HANDLE_DOT_LINE_OVERLAP
655            );
656            assert!(
657                b.y >= top - eps,
658                "{kind:?} handle must not extend above the line box"
659            );
660        }
661    }
662
663    /// The wrap-aware caret line lookup (bug d): a caret on a wrapped line's
664    /// later visual line must resolve to that visual line (not the logical
665    /// line's first visual line), with the correct line-start byte so its x is
666    /// measured from the start of the visual line.
667    #[test]
668    fn caret_visual_line_resolves_wrapped_visual_lines() {
669        // "aaaa bbbb" wrapped into ["aaaa " (0..5), "bbbb" (5..9)], then a hard
670        // newline to a short line "cc" (10..12).
671        let ranges = vec![0..5usize, 5..9, 10..12];
672
673        // Start of the first visual line.
674        assert_eq!(
675            caret_visual_line(&ranges, 0, LineAffinity::Downstream),
676            (0, 0)
677        );
678        // Middle of the first visual line.
679        assert_eq!(
680            caret_visual_line(&ranges, 3, LineAffinity::Downstream),
681            (0, 0)
682        );
683        // Start of the second (wrapped) visual line.
684        assert_eq!(
685            caret_visual_line(&ranges, 5, LineAffinity::Downstream),
686            (1, 5)
687        );
688        // Middle of the second visual line — must NOT resolve to line 0.
689        assert_eq!(
690            caret_visual_line(&ranges, 7, LineAffinity::Downstream),
691            (1, 5)
692        );
693        // End of the wrapped logical line.
694        assert_eq!(
695            caret_visual_line(&ranges, 9, LineAffinity::Downstream),
696            (1, 5)
697        );
698        // The line after the hard newline.
699        assert_eq!(
700            caret_visual_line(&ranges, 11, LineAffinity::Downstream),
701            (2, 10)
702        );
703        // End of text.
704        assert_eq!(
705            caret_visual_line(&ranges, 12, LineAffinity::Downstream),
706            (2, 10)
707        );
708    }
709
710    #[test]
711    fn start_handle_grab_never_drifts() {
712        // The start handle's dot rides ABOVE the line: the finger covers
713        // nothing, so its drag follows the finger with the captured offset
714        // exactly — no visibility drift (user-directed).
715        let mut grab = HandleGrabOffset::begin_for(108.0, 100.0, false);
716        assert_eq!(grab.track(108.0), 8.0);
717        assert_eq!(grab.track(160.0), 8.0, "no drift on long downward travel");
718        assert_eq!(grab.drift_progress(), 0.0);
719    }
720
721    #[test]
722    fn grab_offset_has_follow_drift_and_strict_phases() {
723        let mut grab = HandleGrabOffset::begin(108.0, 100.0);
724        assert_eq!(grab.bias(), 8.0);
725
726        let direct_bias = grab.track(108.0);
727        assert_eq!(direct_bias, 8.0, "initial travel follows exactly");
728        assert_eq!(108.0 + direct_bias, 116.0);
729
730        let drifting_bias = grab.track(132.0);
731        assert!(drifting_bias < 8.0 && drifting_bias > grab_bias_full_view());
732        assert!((0.0..1.0).contains(&grab.drift_progress()));
733
734        assert_eq!(grab.track(156.0), grab_bias_full_view());
735        assert_eq!(grab.drift_progress(), 1.0);
736        assert_eq!(grab.track(220.0), grab_bias_full_view());
737    }
738
739    #[test]
740    fn grab_offset_is_cadence_independent_and_never_unwinds() {
741        let mut single = HandleGrabOffset::begin(108.0, 100.0);
742        single.track(140.0);
743
744        let mut sampled = HandleGrabOffset::begin(108.0, 100.0);
745        for y in [104.0, 109.0, 116.0, 130.0, 140.0] {
746            sampled.track(y);
747        }
748        assert_eq!(sampled.bias(), single.bias());
749        assert_eq!(sampled.drift_progress(), single.drift_progress());
750
751        let migrated = sampled.bias();
752        sampled.track(90.0);
753        assert_eq!(
754            sampled.bias(),
755            migrated,
756            "upward travel cannot unwind drift"
757        );
758
759        let deep = grab_bias_full_view() - 10.0;
760        let mut already_visible = HandleGrabOffset::begin(deep, 0.0);
761        already_visible.track(100.0);
762        assert_eq!(already_visible.bias(), deep);
763    }
764
765    #[test]
766    fn caret_visual_line_handles_empty_ranges() {
767        assert_eq!(caret_visual_line(&[], 5, LineAffinity::Upstream), (0, 0));
768        assert_eq!(caret_visual_line(&[], 5, LineAffinity::Downstream), (0, 0));
769    }
770
771    /// A soft-wrap boundary byte is BOTH the end of the upper visual line and
772    /// the start of the lower one. A finger dragging a selection END (or the
773    /// caret/cursor handle, or the loupe) along the upper line's right edge
774    /// produces exactly that byte — upstream affinity must keep the anchor on
775    /// the upper line's end instead of snapping one line down to the left
776    /// edge (the reported wrapped-multiline handle Y-offset bug).
777    #[test]
778    fn caret_visual_line_upstream_anchors_shared_wrap_boundary_to_upper_line() {
779        let ranges = vec![0..5usize, 5..9, 10..12];
780
781        // The shared boundary resolves per affinity.
782        assert_eq!(
783            caret_visual_line(&ranges, 5, LineAffinity::Upstream),
784            (0, 0)
785        );
786        assert_eq!(
787            caret_visual_line(&ranges, 5, LineAffinity::Downstream),
788            (1, 5)
789        );
790
791        // Mid-line offsets are affinity-independent.
792        assert_eq!(
793            caret_visual_line(&ranges, 3, LineAffinity::Upstream),
794            (0, 0)
795        );
796        assert_eq!(
797            caret_visual_line(&ranges, 7, LineAffinity::Upstream),
798            (1, 5)
799        );
800
801        // A hard-newline boundary is NOT shared (the ranges gap over the
802        // separator): upstream must not pull the lower line's start up.
803        assert_eq!(
804            caret_visual_line(&ranges, 10, LineAffinity::Upstream),
805            (2, 10)
806        );
807
808        // End of text stays on the last line under either affinity.
809        assert_eq!(
810            caret_visual_line(&ranges, 12, LineAffinity::Upstream),
811            (2, 10)
812        );
813    }
814
815    #[test]
816    fn handle_drag_keeps_edges_from_crossing() {
817        // Dragging the end handle left past the start clamps to start+1.
818        assert_eq!(
819            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
820            (5, 6)
821        );
822        // Dragging the end handle right extends normally.
823        assert_eq!(
824            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
825            (5, 12)
826        );
827        // Dragging the start handle right past the end clamps to end-1.
828        assert_eq!(
829            selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
830            (7, 8)
831        );
832        // Dragging the start handle left extends normally.
833        assert_eq!(
834            selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
835            (3, 8)
836        );
837        // The cursor handle moves a collapsed caret.
838        assert_eq!(
839            selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
840            (9, 9)
841        );
842    }
843}