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/// How many consecutive taps a press represents, mirroring the platform text
13/// selection gestures: one tap places the cursor, two select the word, three
14/// select the line/paragraph.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum TapCount {
17 Single,
18 Double,
19 Triple,
20}
21
22impl TapCount {
23 /// The 1-based tap number, capped at three.
24 pub fn as_u8(self) -> u8 {
25 match self {
26 TapCount::Single => 1,
27 TapCount::Double => 2,
28 TapCount::Triple => 3,
29 }
30 }
31}
32
33impl TryFrom<u8> for TapCount {
34 type Error = ();
35
36 fn try_from(value: u8) -> Result<Self, Self::Error> {
37 match value {
38 1 => Ok(TapCount::Single),
39 2 => Ok(TapCount::Double),
40 3 => Ok(TapCount::Triple),
41 _ => Err(()),
42 }
43 }
44}
45
46/// Maximum time between taps that still counts as a multi-tap, in milliseconds.
47pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
48
49/// Maximum distance (px) between consecutive taps that still counts as a
50/// multi-tap. A tap that lands far from the previous one starts a fresh
51/// single tap even if it arrives quickly, matching Android's `ViewConfiguration`
52/// double-tap slop behavior.
53pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
54
55/// Classifies a press into a tap count from the previous tap's count, the time
56/// since it, and the distance from it.
57///
58/// `previous` is the last tap's `(count, x, y)` or `None` for the first tap.
59/// A tap escalates the count (single -> double -> triple, then wraps back to
60/// single) only when it lands within both the timeout and the slop radius;
61/// otherwise it restarts at a single tap.
62pub fn classify_tap(
63 previous: Option<(TapCount, f32, f32)>,
64 elapsed_ms: u128,
65 x: f32,
66 y: f32,
67 timeout_ms: u128,
68 slop_px: f32,
69) -> TapCount {
70 let Some((prev_count, prev_x, prev_y)) = previous else {
71 return TapCount::Single;
72 };
73 let within_time = elapsed_ms <= timeout_ms;
74 let dx = x - prev_x;
75 let dy = y - prev_y;
76 let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
77 if !within_time || !within_slop {
78 return TapCount::Single;
79 }
80 match prev_count {
81 TapCount::Single => TapCount::Double,
82 TapCount::Double => TapCount::Triple,
83 // A fourth tap cycles back to a single cursor placement.
84 TapCount::Triple => TapCount::Single,
85 }
86}
87
88/// Returns the byte range `[start, end)` of the line/paragraph containing
89/// `pos`, delimited by `\n` (the newline itself is excluded from the range).
90///
91/// Used for triple-tap line/paragraph selection. Byte offsets always land on
92/// `char` 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/// Which selection handle a teardrop represents.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
105pub enum HandleKind {
106 /// The blinking-cursor handle shown for a collapsed selection: a teardrop
107 /// whose tip points up at the cursor, centered under it.
108 Cursor,
109 /// The start (leftmost) selection handle: tip at the top-right, bulb below-left.
110 SelectionStart,
111 /// The end (rightmost) selection handle: tip at the top-left, bulb below-right.
112 SelectionEnd,
113}
114
115/// Radius of a selection/cursor handle bulb in px (Android uses ~11dp).
116pub const HANDLE_RADIUS: f32 = 8.0;
117
118/// SVG path data for a handle teardrop whose tip sits at `(tip_x, tip_y)`.
119///
120/// The tip is anchored at the text edge (the cursor position or a selection
121/// endpoint at the line's bottom) and the rounded bulb hangs below it, so the
122/// caller positions the handle by passing the on-screen anchor point.
123pub fn handle_path_data(kind: HandleKind, tip_x: f32, tip_y: f32, radius: f32) -> String {
124 let r = radius.max(0.0);
125 let cy = tip_y + r; // bulb center y
126 match kind {
127 HandleKind::Cursor => {
128 // Symmetric teardrop: tip up, full circular bulb below.
129 format!(
130 "M {tip_x} {tip_y} L {left} {cy} A {r} {r} 0 1 0 {right} {cy} Z",
131 left = tip_x - r,
132 right = tip_x + r,
133 )
134 }
135 HandleKind::SelectionStart => {
136 // Android start (left) handle: the point sits at the TOP-RIGHT
137 // (touching the selection start) with a straight vertical right edge,
138 // and the round bulb hangs down and to the LEFT. Traced tip → straight
139 // down the right edge → arc round to the left → back to the tip.
140 format!(
141 "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 0 {left} {tip_y} Z",
142 left = tip_x - r,
143 )
144 }
145 HandleKind::SelectionEnd => {
146 // Android end (right) handle: the exact mirror of the start handle —
147 // the point sits at the TOP-LEFT (touching the selection end) with a
148 // straight vertical left edge, and the round bulb hangs down and to
149 // the RIGHT. Same trace as the start handle with the arc swept the
150 // other way so it is a true reflection (not rotated).
151 format!(
152 "M {tip_x} {tip_y} L {tip_x} {cy} A {r} {r} 0 1 1 {right} {tip_y} Z",
153 right = tip_x + r,
154 )
155 }
156 }
157}
158
159/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
160/// its touch target, matching Android's generous handle hit area. A bare
161/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
162/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
163/// slop the press falls through to the field below and places a caret, which
164/// collapses the selection. The slop is applied to the sides and BELOW the tip
165/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
166/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
167/// so a double-tap still reaches the field to escalate into a word selection.
168pub const HANDLE_GRAB_SLOP: f32 = 24.0;
169
170/// Computes the selection `(min, max)` that results from dragging one handle to
171/// a new text `offset`, keeping the opposite (fixed) edge anchored.
172///
173/// Dragging never lets the two edges cross: a dragged start clamps to just
174/// before the fixed end, and a dragged end clamps to just after the fixed
175/// start, so the selection keeps at least one selected unit.
176pub fn selection_after_handle_drag(
177 dragged: HandleKind,
178 fixed_edge: usize,
179 dragged_offset: usize,
180 text_len: usize,
181) -> (usize, usize) {
182 let fixed = fixed_edge.min(text_len);
183 let dragged_offset = dragged_offset.min(text_len);
184 match dragged {
185 HandleKind::SelectionStart => {
186 let start = dragged_offset.min(fixed.saturating_sub(1));
187 (start, fixed)
188 }
189 HandleKind::SelectionEnd => {
190 let end = dragged_offset.max(fixed + 1).min(text_len);
191 (fixed, end)
192 }
193 // The cursor handle just moves the collapsed caret.
194 HandleKind::Cursor => (dragged_offset, dragged_offset),
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn tap_classification_escalates_within_time_and_slop() {
204 assert_eq!(
205 classify_tap(None, 0, 10.0, 10.0, 500, 24.0),
206 TapCount::Single
207 );
208 assert_eq!(
209 classify_tap(
210 Some((TapCount::Single, 10.0, 10.0)),
211 100,
212 11.0,
213 12.0,
214 500,
215 24.0
216 ),
217 TapCount::Double
218 );
219 assert_eq!(
220 classify_tap(
221 Some((TapCount::Double, 10.0, 10.0)),
222 100,
223 11.0,
224 12.0,
225 500,
226 24.0
227 ),
228 TapCount::Triple
229 );
230 // A fourth quick tap wraps back to a single cursor placement.
231 assert_eq!(
232 classify_tap(
233 Some((TapCount::Triple, 10.0, 10.0)),
234 100,
235 11.0,
236 12.0,
237 500,
238 24.0
239 ),
240 TapCount::Single
241 );
242 }
243
244 #[test]
245 fn tap_classification_resets_past_timeout_or_slop() {
246 // Too slow: restarts.
247 assert_eq!(
248 classify_tap(
249 Some((TapCount::Single, 10.0, 10.0)),
250 600,
251 10.0,
252 10.0,
253 500,
254 24.0
255 ),
256 TapCount::Single
257 );
258 // Too far: restarts even though it is quick.
259 assert_eq!(
260 classify_tap(
261 Some((TapCount::Single, 10.0, 10.0)),
262 50,
263 100.0,
264 10.0,
265 500,
266 24.0
267 ),
268 TapCount::Single
269 );
270 }
271
272 #[test]
273 fn line_boundaries_span_between_newlines() {
274 let text = "first line\nsecond line\nthird";
275 // Inside the second line.
276 assert_eq!(find_line_boundaries(text, 15), (11, 22));
277 // Start of the first line.
278 assert_eq!(find_line_boundaries(text, 0), (0, 10));
279 // Inside the last (newline-terminated-absent) line.
280 assert_eq!(find_line_boundaries(text, 25), (23, text.len()));
281 }
282
283 #[test]
284 fn line_boundaries_handle_unicode_and_empty_lines() {
285 let text = "\u{00e9}\u{00e8}\n\n\u{4e2d}\u{6587}";
286 // Empty middle line: start == end at the byte after the first newline.
287 let (start, end) = find_line_boundaries(text, "\u{00e9}\u{00e8}\n".len());
288 assert_eq!(start, end);
289 // Last line spans the two CJK characters.
290 let last = find_line_boundaries(text, text.len());
291 assert_eq!(&text[last.0..last.1], "\u{4e2d}\u{6587}");
292 }
293
294 #[test]
295 fn handle_path_is_non_empty_and_contains_the_tip() {
296 for kind in [
297 HandleKind::Cursor,
298 HandleKind::SelectionStart,
299 HandleKind::SelectionEnd,
300 ] {
301 let data = handle_path_data(kind, 40.0, 20.0, HANDLE_RADIUS);
302 let path = cranpose_ui_graphics::VectorPath::parse(&data)
303 .expect("handle path must be valid SVG");
304 assert!(!path.is_empty(), "{kind:?} handle must have geometry");
305 let bounds = path.bounds();
306 // The tip (40, 20) must lie within the shape's bounds.
307 assert!(bounds.x <= 40.0 + 0.5 && bounds.x + bounds.width >= 40.0 - 0.5);
308 assert!(bounds.y <= 20.0 + 0.5);
309 // The bulb hangs below the tip.
310 assert!(bounds.y + bounds.height >= 20.0 + HANDLE_RADIUS);
311 }
312 }
313
314 #[test]
315 fn handle_drag_keeps_edges_from_crossing() {
316 // Dragging the end handle left past the start clamps to start+1.
317 assert_eq!(
318 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 2, 20),
319 (5, 6)
320 );
321 // Dragging the end handle right extends normally.
322 assert_eq!(
323 selection_after_handle_drag(HandleKind::SelectionEnd, 5, 12, 20),
324 (5, 12)
325 );
326 // Dragging the start handle right past the end clamps to end-1.
327 assert_eq!(
328 selection_after_handle_drag(HandleKind::SelectionStart, 8, 10, 20),
329 (7, 8)
330 );
331 // Dragging the start handle left extends normally.
332 assert_eq!(
333 selection_after_handle_drag(HandleKind::SelectionStart, 8, 3, 20),
334 (3, 8)
335 );
336 // The cursor handle moves a collapsed caret.
337 assert_eq!(
338 selection_after_handle_drag(HandleKind::Cursor, 4, 9, 20),
339 (9, 9)
340 );
341 }
342}