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
12use cranpose_ui_graphics::Rect;
13
14/// How many consecutive taps a press represents, mirroring the platform text
15/// selection gestures: one tap places the cursor, two select the word, three
16/// select the line/paragraph.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TapCount {
19    Single,
20    Double,
21    Triple,
22}
23
24impl TapCount {
25    /// The 1-based tap number, capped at three.
26    pub fn as_u8(self) -> u8 {
27        match self {
28            TapCount::Single => 1,
29            TapCount::Double => 2,
30            TapCount::Triple => 3,
31        }
32    }
33}
34
35impl TryFrom<u8> for TapCount {
36    type Error = ();
37
38    fn try_from(value: u8) -> Result<Self, Self::Error> {
39        match value {
40            1 => Ok(TapCount::Single),
41            2 => Ok(TapCount::Double),
42            3 => Ok(TapCount::Triple),
43            _ => Err(()),
44        }
45    }
46}
47
48/// Maximum time between taps that still counts as a multi-tap, in milliseconds.
49pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
50
51/// Maximum distance (px) between consecutive taps that still counts as a
52/// multi-tap. A tap that lands far from the previous one starts a fresh
53/// single tap even if it arrives quickly, matching Android's `ViewConfiguration`
54/// double-tap slop behavior.
55pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
56
57/// Classifies a press into a tap count from the previous tap's count, the time
58/// since it, and the distance from it.
59///
60/// `previous` is the last tap's `(count, x, y)` or `None` for the first tap.
61/// A tap escalates the count (single -> double -> triple, then wraps back to
62/// single) only when it lands within both the timeout and the slop radius;
63/// otherwise it restarts at a single tap.
64pub fn classify_tap(
65    previous: Option<(TapCount, f32, f32)>,
66    elapsed_ms: u128,
67    x: f32,
68    y: f32,
69    timeout_ms: u128,
70    slop_px: f32,
71) -> TapCount {
72    let Some((prev_count, prev_x, prev_y)) = previous else {
73        return TapCount::Single;
74    };
75    let within_time = elapsed_ms <= timeout_ms;
76    let dx = x - prev_x;
77    let dy = y - prev_y;
78    let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
79    if !within_time || !within_slop {
80        return TapCount::Single;
81    }
82    match prev_count {
83        TapCount::Single => TapCount::Double,
84        TapCount::Double => TapCount::Triple,
85        // A fourth tap cycles back to a single cursor placement.
86        TapCount::Triple => TapCount::Single,
87    }
88}
89
90/// Returns the byte range `[start, end)` of the line/paragraph containing
91/// `pos`, delimited by `\n` (the newline itself is excluded from the range).
92///
93/// Used for triple-tap line/paragraph selection. Byte offsets always land on
94/// `char` boundaries because `\n` is a single-byte ASCII character.
95pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
96    let pos = pos.min(text.len());
97    let start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
98    let end = text[pos..]
99        .find('\n')
100        .map(|i| pos + i)
101        .unwrap_or(text.len());
102    (start, end)
103}
104
105/// Which selection handle a teardrop represents.
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub enum HandleKind {
108    /// The blinking-cursor handle shown for a collapsed selection: a teardrop
109    /// whose tip points up at the cursor, centered under it.
110    Cursor,
111    /// The start (leftmost) selection handle: tip at the top-right, bulb below-left.
112    SelectionStart,
113    /// The end (rightmost) selection handle: tip at the top-left, bulb below-right.
114    SelectionEnd,
115}
116
117/// Radius of a selection/cursor handle bulb in px (Android uses ~11dp).
118pub const HANDLE_RADIUS: f32 = 8.0;
119
120/// Extra hit-test slop (px) around a handle so it is easy to grab with a finger.
121pub const HANDLE_TOUCH_SLOP: f32 = 12.0;
122
123/// SVG path data for a handle teardrop whose tip sits at `(tip_x, tip_y)`.
124///
125/// The tip is anchored at the text edge (the cursor position or a selection
126/// endpoint at the line's bottom) and the rounded bulb hangs below it, so the
127/// caller positions the handle by passing the on-screen anchor point.
128pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
129    let r = radius.max(0.0);
130    let cy = tip_y + r; // bulb center y
131    match kind {
132        HandleKind::Cursor => {
133            // Symmetric teardrop: tip up, full circular bulb below.
134            format!(
135                "M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
136                left = tip_x - r,
137                right = tip_x + r,
138            )
139        }
140        HandleKind::SelectionStart => {
141            // Tip at the endpoint, bulb hanging down and to the LEFT.
142            format!(
143                "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {left} {tip_y} Z",
144                left = tip_x - r,
145            )
146        }
147        HandleKind::SelectionEnd => {
148            // Tip at the endpoint, bulb hanging down and to the RIGHT.
149            format!(
150                "M {tip_x} {tip_y} L {right} {tip_y} A {r} {r} 0 1 0 {tip_x} {cy} Z",
151                right = tip_x + r,
152            )
153        }
154    }
155}
156
157/// Extra padding (px) added around a handle's drawn teardrop to enlarge the
158/// finger touch target, matching Android's generous handle hit area.
159pub const HANDLE_TOUCH_PADDING: f32 = 12.0;
160
161/// The axis-aligned hit region for a handle, expanded by touch slop, used to
162/// decide whether a pointer-down grabbed a handle.
163pub fn handle_hit_rect(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32, slop: f32) -> Rect {
164    let r = radius.max(0.0);
165    let slop = slop.max(0.0);
166    // Horizontal span of the bulb relative to the tip depends on the handle side.
167    let (left, right) = match kind {
168        HandleKind::Cursor => (tip_x - r, tip_x + r),
169        HandleKind::SelectionStart => (tip_x - 2.0 * r, tip_x + r),
170        HandleKind::SelectionEnd => (tip_x - r, tip_x + 2.0 * r),
171    };
172    Rect {
173        x: left - slop,
174        y: tip_y - slop,
175        width: (right - left) + 2.0 * slop,
176        height: 2.0 * r + 2.0 * slop,
177    }
178}
179
180/// Returns the handle nearest to `(x, y)` whose slop-expanded hit region
181/// contains the point, or `None` when the point misses every handle.
182///
183/// `handles` lists the currently drawn handles as `(kind, tip_x, tip_y)`.
184pub fn hit_test_handles(
185    handles: &[(HandleKind, f32, f32)],
186    x: f32,
187    y: f32,
188    radius: f32,
189    slop: f32,
190) -> Option<HandleKind> {
191    let mut best: Option<(HandleKind, f32)> = None;
192    for &(kind, tip_x, tip_y) in handles {
193        let rect = handle_hit_rect(kind, tip_x, tip_y, radius, slop);
194        let inside =
195            x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
196        if !inside {
197            continue;
198        }
199        let cx = tip_x;
200        let cy = tip_y + radius;
201        let dist_sq = (x - cx) * (x - cx) + (y - cy) * (y - cy);
202        if best
203            .map(|(_, best_dist)| dist_sq < best_dist)
204            .unwrap_or(true)
205        {
206            best = Some((kind, dist_sq));
207        }
208    }
209    best.map(|(kind, _)| kind)
210}
211
212/// Computes the selection `(min, max)` that results from dragging one handle to
213/// a new text `offset`, keeping the opposite (fixed) edge anchored.
214///
215/// Dragging never lets the two edges cross: a dragged start clamps to just
216/// before the fixed end, and a dragged end clamps to just after the fixed
217/// start, so the selection keeps at least one selected unit.
218pub fn selection_after_handle_drag(
219    dragged: HandleKind,
220    fixed_edge: usize,
221    dragged_offset: usize,
222    text_len: usize,
223) -> (usize, usize) {
224    let fixed = fixed_edge.min(text_len);
225    let dragged_offset = dragged_offset.min(text_len);
226    match dragged {
227        HandleKind::SelectionStart => {
228            let start = dragged_offset.min(fixed.saturating_sub(1));
229            (start, fixed)
230        }
231        HandleKind::SelectionEnd => {
232            let end = dragged_offset.max(fixed + 1).min(text_len);
233            (fixed, end)
234        }
235        // The cursor handle just moves the collapsed caret.
236        HandleKind::Cursor => (dragged_offset, dragged_offset),
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn tap_classification_escalates_within_time_and_slop() {
246        assert_eq!(
247            classify_tap(None, 0, 10.0, 10.0, 500, 24.0),
248            TapCount::Single
249        );
250        assert_eq!(
251            classify_tap(
252                Some((TapCount::Single, 10.0, 10.0)),
253                100,
254                11.0,
255                12.0,
256                500,
257                24.0
258            ),
259            TapCount::Double
260        );
261        assert_eq!(
262            classify_tap(
263                Some((TapCount::Double, 10.0, 10.0)),
264                100,
265                11.0,
266                12.0,
267                500,
268                24.0
269            ),
270            TapCount::Triple
271        );
272        // A fourth quick tap wraps back to a single cursor placement.
273        assert_eq!(
274            classify_tap(
275                Some((TapCount::Triple, 10.0, 10.0)),
276                100,
277                11.0,
278                12.0,
279                500,
280                24.0
281            ),
282            TapCount::Single
283        );
284    }
285
286    #[test]
287    fn tap_classification_resets_past_timeout_or_slop() {
288        // Too slow: restarts.
289        assert_eq!(
290            classify_tap(
291                Some((TapCount::Single, 10.0, 10.0)),
292                600,
293                10.0,
294                10.0,
295                500,
296                24.0
297            ),
298            TapCount::Single
299        );
300        // Too far: restarts even though it is quick.
301        assert_eq!(
302            classify_tap(
303                Some((TapCount::Single, 10.0, 10.0)),
304                50,
305                100.0,
306                10.0,
307                500,
308                24.0
309            ),
310            TapCount::Single
311        );
312    }
313
314    #[test]
315    fn line_boundaries_span_between_newlines() {
316        let text = "first line\nsecond line\nthird";
317        // Inside the second line.
318        assert_eq!(find_line_boundaries(text, 15), (11, 22));
319        // Start of the first line.
320        assert_eq!(find_line_boundaries(text, 0), (0, 10));
321        // Inside the last (newline-terminated-absent) line.
322        assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
323    }
324
325    #[test]
326    fn line_boundaries_handle_unicode_and_empty_lines() {
327        let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
328        // Empty middle line: start == end at the byte after the first newline.
329        let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
330        assert_eq!(start, end);
331        // Last line spans the two CJK characters.
332        let last = find_line_boundaries(text, text.len());
333        assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
334    }
335
336    #[test]
337    fn handle_path_is_non_empty_and_contains_the_tip() {
338        for kind in [
339            HandleKind::Cursor,
340            HandleKind::SelectionStart,
341            HandleKind::SelectionEnd,
342        ] {
343            let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
344            let path = cranpose_ui_graphics::VectorPath::parse(&data)
345                .expect("handle path must be valid SVG");
346            assert!(!path.is_empty(), "{kind:?} handle must have geometry");
347            let bounds = path.bounds();
348            // The tip (40, 20) must lie within the shape's bounds.
349            assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
350            assert!(bounds.y <= 20.0 + 0.5);
351            // The bulb hangs below the tip.
352            assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
353        }
354    }
355
356    #[test]
357    fn handle_hit_rect_covers_tip_and_bulb_with_slop() {
358        let rect = handle_hit_rect(
359            HandleKind::Cursor,
360            40.0,
361            20.0,
362            HANDLE_RADIUS,
363            HANDLE_TOUCH_SLOP,
364        );
365        // Tip and bulb center are inside.
366        assert!(rect.x <= 40.0 && 40.0 <= rect.x + rect.width);
367        assert!(rect.y <= 20.0 && 20.0 + HANDLE_RADIUS <= rect.y + rect.height);
368        // Slop widens the region beyond the bulb radius.
369        assert!(rect.width >= 2.0 * HANDLE_RADIUS + 2.0 * HANDLE_TOUCH_SLOP - 0.01);
370    }
371
372    #[test]
373    fn hit_test_prefers_the_nearest_handle() {
374        let handles = [
375            (HandleKind::SelectionStart, 20.0, 20.0),
376            (HandleKind::SelectionEnd, 120.0, 20.0),
377        ];
378        // Near the start handle bulb.
379        assert_eq!(
380            hit_test_handles(&handles, 20.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
381            Some(HandleKind::SelectionStart)
382        );
383        // Near the end handle bulb.
384        assert_eq!(
385            hit_test_handles(&handles, 120.0, 28.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
386            Some(HandleKind::SelectionEnd)
387        );
388        // Far from both.
389        assert_eq!(
390            hit_test_handles(&handles, 300.0, 300.0, HANDLE_RADIUS, HANDLE_TOUCH_SLOP),
391            None
392        );
393    }
394
395    #[test]
396    fn handle_drag_keeps_edges_from_crossing() {
397        // Dragging the end handle left past the start clamps to start+1.
398        assert_eq!(
399            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
400            (5, 6)
401        );
402        // Dragging the end handle right extends normally.
403        assert_eq!(
404            selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
405            (5, 12)
406        );
407        // Dragging the start handle right past the end clamps to end-1.
408        assert_eq!(
409            selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
410            (7, 8)
411        );
412        // Dragging the start handle left extends normally.
413        assert_eq!(
414            selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
415            (3, 8)
416        );
417        // The cursor handle moves a collapsed caret.
418        assert_eq!(
419            selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
420            (9, 9)
421        );
422    }
423}