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/// Maps a 1-based tap count to the granularity it selects.
72///
73/// A single tap places the caret; two taps select the word, three the line,
74/// four the paragraph, and every further tap cycles back through
75/// word → line → paragraph so a resting finger keeps toggling between the three
76/// range granularities (matching desktop editors and iOS).
77pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
78    match tap_count {
79        0 | 1 => SelectionGranularity::Caret,
80        n => match (n - 2) % 3 {
81            0 => SelectionGranularity::Word,
82            1 => SelectionGranularity::Line,
83            _ => SelectionGranularity::Paragraph,
84        },
85    }
86}
87
88/// Returns the byte range `[start, end)` of the line containing `pos`, delimited
89/// by `\n` (the newline itself is excluded from the range).
90///
91/// Used for triple-tap line selection. Byte offsets always land on `char`
92/// boundaries because `\n` is a single-byte ASCII character.
93pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
94    let pos = pos.min(text.len());
95    let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
96    let end = text[pos..]
97        .find('\n')
98        .map(|i| pos + i)
99        .unwrap_or(text.len());
100    (start, end)
101}
102
103/// Returns the byte range `[start, end)` of the paragraph containing `pos`.
104///
105/// Paragraphs are delimited by blank lines — a run of two or more consecutive
106/// `\n` — so a fourth tap grows the selection from one line to the whole block
107/// of text around it. Text with no blank line is a single paragraph (the whole
108/// string). Byte offsets land on `char` boundaries because `\n` is single-byte
109/// ASCII. Unicode-aware: multi-byte characters inside the paragraph are spanned
110/// whole.
111pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
112    let pos = pos.min(text.len());
113    // Start: just after the last blank-line separator at or before `pos`.
114    let start = text[..pos]
115        .rfind("\n\n")
116        .map(|i| {
117            // Skip the whole run of blank lines so the paragraph starts on its
118            // first non-empty line.
119            let mut s = i + 1;
120            while text[s..].starts_with('\n') {
121                s += 1;
122            }
123            s
124        })
125        .unwrap_or(0);
126    // End: the next blank-line separator at or after `pos`.
127    let end = text[pos..]
128        .find("\n\n")
129        .map(|i| pos + i)
130        .unwrap_or(text.len());
131    (start.min(end), end)
132}
133
134/// Which selection handle a teardrop represents.
135#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
136pub enum HandleKind {
137    /// The blinking-cursor handle shown for a collapsed selection: a teardrop
138    /// whose tip points up at the cursor, centered under it.
139    Cursor,
140    /// The start (leftmost) selection handle: tip at the top-right, bulb below-left.
141    SelectionStart,
142    /// The end (rightmost) selection handle: tip at the top-left, bulb below-right.
143    SelectionEnd,
144}
145
146/// Radius of a selection/cursor handle bulb in px (Android uses ~11dp).
147pub const HANDLE_RADIUS: f32 = 8.0;
148
149/// SVG path data for a handle teardrop whose tip sits at `(tip_x, tip_y)`.
150///
151/// The tip is anchored at the text edge (the cursor position or a selection
152/// endpoint at the line's bottom) and the rounded bulb hangs below it, so the
153/// caller positions the handle by passing the on-screen anchor point.
154pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
155    let r = radius.max(0.0);
156    let cy = tip_y + r; // bulb center y
157    match kind {
158        HandleKind::Cursor => {
159            // Symmetric teardrop: tip up, full circular bulb below.
160            format!(
161                "M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
162                left = tip_x - r,
163                right = tip_x + r,
164            )
165        }
166        HandleKind::SelectionStart => {
167            // Android start (left) handle: the point sits at the TOP-RIGHT
168            // (touching the selection start) with a straight vertical right edge,
169            // and the round bulb hangs down and to the LEFT. Traced tip → straight
170            // down the right edge → 270° arc round the bulb (whose centre sits at
171            // `(tip_x - r, cy)`, down-left of the tip) → back along the top edge to
172            // the tip.
173            //
174            // The sweep flag is `1` (clockwise, y-down): together with the
175            // large-arc flag this centres the arc on the bulb below-left of the
176            // tip. Sweep `0` would instead centre the arc on the tip itself,
177            // drawing an upward pac-man wedge that overlaps the glyph line — the
178            // reported "inverted teardrop".
179            format!(
180                "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 1 {left} {tip_y} Z",
181                left = tip_x - r,
182            )
183        }
184        HandleKind::SelectionEnd => {
185            // Android end (right) handle: the exact mirror of the start handle —
186            // the point sits at the TOP-LEFT (touching the selection end) with a
187            // straight vertical left edge, and the round bulb hangs down and to
188            // the RIGHT. Same trace as the start handle with the arc swept the
189            // other way (sweep `0`) so it is a true reflection (not rotated): the
190            // bulb centre sits at `(tip_x + r, cy)`, down-right of the tip.
191            format!(
192                "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {right} {tip_y} Z",
193                right = tip_x + r,
194            )
195        }
196    }
197}
198
199/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
200/// its touch target, matching Android's generous handle hit area. A bare
201/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
202/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
203/// slop the press falls through to the field below and places a caret, which
204/// collapses the selection. The slop is applied to the sides and BELOW the tip
205/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
206/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
207/// so a double-tap still reaches the field to escalate into a word selection.
208pub const HANDLE_GRAB_SLOP: f32 = 24.0;
209
210/// Computes the selection `(min, max)` that results from dragging one handle to
211/// a new text `offset`, keeping the opposite (fixed) edge anchored.
212///
213/// Dragging never lets the two edges cross: a dragged start clamps to just
214/// before the fixed end, and a dragged end clamps to just after the fixed
215/// start, so the selection keeps at least one selected unit.
216pub fn selection_after_handle_drag(
217    dragged: HandleKind,
218    fixed_edge: usize,
219    dragged_offset: usize,
220    text_len: usize,
221) -> (usize, usize) {
222    let fixed = fixed_edge.min(text_len);
223    let dragged_offset = dragged_offset.min(text_len);
224    match dragged {
225        HandleKind::SelectionStart => {
226            let start = dragged_offset.min(fixed.saturating_sub(1));
227            (start, fixed)
228        }
229        HandleKind::SelectionEnd => {
230            let end = dragged_offset.max(fixed + 1).min(text_len);
231            (fixed, end)
232        }
233        // The cursor handle just moves the collapsed caret.
234        HandleKind::Cursor => (dragged_offset, dragged_offset),
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn tap_classification_escalates_within_time_and_slop() {
244        assert_eq!(classify_tap_count(None, 0, 10.0, 10.0, 500, 24.0), 1);
245        assert_eq!(
246            classify_tap_count(Some((1, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
247            2
248        );
249        assert_eq!(
250            classify_tap_count(Some((2, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
251            3
252        );
253        // A fourth in-place tap keeps counting up (the granularity mapping is
254        // what cycles, not the raw count).
255        assert_eq!(
256            classify_tap_count(Some((3, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
257            4
258        );
259        assert_eq!(
260            classify_tap_count(Some((4, 10.0, 10.0)), 100, 11.0, 12.0, 500, 24.0),
261            5
262        );
263    }
264
265    #[test]
266    fn tap_classification_resets_past_timeout_or_slop() {
267        // Too slow: restarts.
268        assert_eq!(
269            classify_tap_count(Some((1, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
270            1
271        );
272        // Too far: restarts even though it is quick.
273        assert_eq!(
274            classify_tap_count(Some((1, 10.0, 10.0)), 50, 100.0, 10.0, 500, 24.0),
275            1
276        );
277        // A reset also applies from a higher count.
278        assert_eq!(
279            classify_tap_count(Some((3, 10.0, 10.0)), 600, 10.0, 10.0, 500, 24.0),
280            1
281        );
282    }
283
284    #[test]
285    fn tap_granularity_grows_then_cycles() {
286        use SelectionGranularity::*;
287        assert_eq!(tap_selection_granularity(0), Caret);
288        assert_eq!(tap_selection_granularity(1), Caret);
289        assert_eq!(tap_selection_granularity(2), Word);
290        assert_eq!(tap_selection_granularity(3), Line);
291        assert_eq!(tap_selection_granularity(4), Paragraph);
292        // Fifth tap cycles back to word, then line, then paragraph again.
293        assert_eq!(tap_selection_granularity(5), Word);
294        assert_eq!(tap_selection_granularity(6), Line);
295        assert_eq!(tap_selection_granularity(7), Paragraph);
296        assert_eq!(tap_selection_granularity(8), Word);
297    }
298
299    #[test]
300    fn paragraph_boundaries_span_blank_line_delimited_blocks() {
301        let text = "line one\nline two\n\nsecond para\nstill second\n\n\nthird";
302        // Inside the first paragraph (two lines).
303        let (s, e) = find_paragraph_boundaries(text, 3);
304        assert_eq!(&text[s..e], "line one\nline two");
305        // Inside the second paragraph.
306        let (s, e) = find_paragraph_boundaries(text, 20);
307        assert_eq!(&text[s..e], "second para\nstill second");
308        // Inside the third paragraph, after a run of THREE newlines.
309        let (s, e) = find_paragraph_boundaries(text, text.len());
310        assert_eq!(&text[s..e], "third");
311    }
312
313    #[test]
314    fn paragraph_boundaries_no_blank_line_is_whole_text() {
315        let text = "just\none\nblock";
316        assert_eq!(find_paragraph_boundaries(text, 5), (0, text.len()));
317    }
318
319    #[test]
320    fn paragraph_boundaries_are_unicode_aware() {
321        // Multi-byte characters must be spanned whole and offsets stay on char
322        // boundaries.
323        let text = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}\n\n\u{6b21}";
324        let first = "\u{4e2d}\u{6587}\u{6bb5}\u{843d}";
325        let (s, e) = find_paragraph_boundaries(text, 3);
326        assert_eq!(&text[s..e], first);
327        assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
328    }
329
330    #[test]
331    fn line_boundaries_span_between_newlines() {
332        let text = "first line\nsecond line\nthird";
333        // Inside the second line.
334        assert_eq!(find_line_boundaries(text, 15), (11, 22));
335        // Start of the first line.
336        assert_eq!(find_line_boundaries(text, 0), (0, 10));
337        // Inside the last (newline-terminated-absent) line.
338        assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
339    }
340
341    #[test]
342    fn line_boundaries_handle_unicode_and_empty_lines() {
343        let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
344        // Empty middle line: start == end at the byte after the first newline.
345        let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
346        assert_eq!(start, end);
347        // Last line spans the two CJK characters.
348        let last = find_line_boundaries(text, text.len());
349        assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
350    }
351
352    #[test]
353    fn handle_path_is_non_empty_and_contains_the_tip() {
354        for kind in [
355            HandleKind::Cursor,
356            HandleKind::SelectionStart,
357            HandleKind::SelectionEnd,
358        ] {
359            let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
360            let path = cranpose_ui_graphics::VectorPath::parse(&data)
361                .expect("handle path must be valid SVG");
362            assert!(!path.is_empty(), "{kind:?} handle must have geometry");
363            let bounds = path.bounds();
364            // The tip (40, 20) must lie within the shape's bounds.
365            assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
366            assert!(bounds.y <= 20.0 + 0.5);
367            // The bulb hangs below the tip.
368            assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
369        }
370    }
371
372    /// Regression for the inverted-teardrop bug: the drawn selection handles
373    /// must have the correct Android orientation — the whole teardrop sits AT OR
374    /// BELOW the tip line (never above it, into the glyphs), and each handle's
375    /// bulb hangs to the correct side of its tip:
376    ///
377    /// * start (left) handle — point top-right, bulb down-LEFT (all geometry at
378    ///   or left of the tip's x);
379    /// * end (right) handle — point top-left, bulb down-RIGHT (all geometry at or
380    ///   right of the tip's x);
381    /// * cursor handle — symmetric, centred on the tip.
382    ///
383    /// A sweep-flag mistake used to centre the arc on the tip, producing an
384    /// upward pac-man wedge that extended ABOVE the tip and to the wrong side —
385    /// exactly what this guards against.
386    #[test]
387    fn selection_handles_point_at_the_tip_with_the_bulb_below() {
388        let (tip_x, tip_y, r) = (40.0_f32, 20.0_f32, HANDLE_RADIUS);
389        let eps = 0.5_f32;
390
391        let sample_points = |kind: HandleKind| -> Vec<cranpose_ui_graphics::Point> {
392            let data = handle_path_data(kind, tip_x, tip_y, r);
393            let path = cranpose_ui_graphics::VectorPath::parse(&data).expect("valid handle path");
394            path.subpaths().iter().flatten().copied().collect()
395        };
396
397        // No handle draws any geometry ABOVE the tip line — that region belongs to
398        // the glyphs, and a handle poking up into it is the inverted-teardrop bug.
399        for kind in [
400            HandleKind::Cursor,
401            HandleKind::SelectionStart,
402            HandleKind::SelectionEnd,
403        ] {
404            for p in sample_points(kind) {
405                assert!(
406                    p.y >= tip_y - eps,
407                    "{kind:?}: point {p:?} is above the tip line y={tip_y} (teardrop inverted)"
408                );
409            }
410        }
411
412        // Start bulb hangs down-LEFT: every point is at or left of the tip's x,
413        // and the shape reaches a full bulb-width to the left.
414        let start = sample_points(HandleKind::SelectionStart);
415        assert!(
416            start.iter().all(|p| p.x <= tip_x + eps),
417            "start handle must not extend right of its tip"
418        );
419        assert!(
420            start.iter().any(|p| p.x <= tip_x - 2.0 * r + eps),
421            "start handle bulb must reach a full diameter to the LEFT of the tip"
422        );
423
424        // End bulb hangs down-RIGHT: mirror image of the start handle.
425        let end = sample_points(HandleKind::SelectionEnd);
426        assert!(
427            end.iter().all(|p| p.x >= tip_x - eps),
428            "end handle must not extend left of its tip"
429        );
430        assert!(
431            end.iter().any(|p| p.x >= tip_x + 2.0 * r - eps),
432            "end handle bulb must reach a full diameter to the RIGHT of the tip"
433        );
434
435        // Cursor handle is symmetric about the tip: it reaches ~r to each side.
436        let cursor = sample_points(HandleKind::Cursor);
437        assert!(
438            cursor.iter().any(|p| p.x <= tip_x - r + eps)
439                && cursor.iter().any(|p| p.x >= tip_x + r - eps),
440            "cursor handle must be symmetric about the tip"
441        );
442    }
443
444    #[test]
445    fn handle_drag_keeps_edges_from_crossing() {
446        // Dragging the end handle left past the start clamps to start+1.
447        assert_eq!(
448            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
449            (5, 6)
450        );
451        // Dragging the end handle right extends normally.
452        assert_eq!(
453            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
454            (5, 12)
455        );
456        // Dragging the start handle right past the end clamps to end-1.
457        assert_eq!(
458            selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
459            (7, 8)
460        );
461        // Dragging the start handle left extends normally.
462        assert_eq!(
463            selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
464            (3, 8)
465        );
466        // The cursor handle moves a collapsed caret.
467        assert_eq!(
468            selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
469            (9, 9)
470        );
471    }
472}