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_foundation::text::TextRange;
13
14/// Maximum time between taps that still counts as a multi-tap, in milliseconds.
15pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
16
17/// Maximum distance (px) between consecutive taps that still counts as a
18/// multi-tap. A tap that lands far from the previous one starts a fresh
19/// single tap even if it arrives quickly, matching Android's `ViewConfiguration`
20/// double-tap slop behavior.
21pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
22
23/// The unit of text a tap gesture selects, growing with the tap count the way
24/// mature text editors do (Android `TextView`, iOS `UITextView`, VS Code):
25///
26/// * 1 tap → [`Caret`](SelectionGranularity::Caret) (place the cursor);
27/// * 2 taps → [`Word`](SelectionGranularity::Word);
28/// * 3 taps → [`Line`](SelectionGranularity::Line);
29/// * 4 taps → [`Paragraph`](SelectionGranularity::Paragraph);
30/// * 5+ taps → cycle back through word → line → paragraph.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum SelectionGranularity {
33 /// Collapsed caret (a single tap places the cursor).
34 Caret,
35 /// The word under the tap.
36 Word,
37 /// The line under the tap (delimited by `\n`).
38 Line,
39 /// The paragraph under the tap (delimited by blank lines).
40 Paragraph,
41}
42
43/// Classifies a press into a 1-based tap count from the previous tap's count,
44/// the time since it, and the distance from it.
45///
46/// `previous` is the last tap's `(count, x, y)` or `None` for the first tap. A
47/// tap increments the count only when it lands within both the timeout and the
48/// slop radius; otherwise it restarts at `1`. The count is **not** wrapped here
49/// — the granularity mapping ([`tap_selection_granularity`]) cycles instead, so
50/// the field can keep escalating (word → line → paragraph → word …) as long as
51/// the finger keeps tapping in place.
52pub fn classify_tap_count(
53 previous: Option<(u8, f32, f32)>,
54 elapsed_ms: u128,
55 x: f32,
56 y: f32,
57 timeout_ms: u128,
58 slop_px: f32,
59) -> u8 {
60 let Some((prev_count, prev_x, prev_y)) = previous else {
61 return 1;
62 };
63 let within_time = elapsed_ms <= timeout_ms;
64 let dx = x - prev_x;
65 let dy = y - prev_y;
66 let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
67 if !within_time || !within_slop {
68 return 1;
69 }
70 prev_count.saturating_add(1)
71}
72
73/// Resolves the effective tap count for a press, folding in the "tap inside an
74/// existing selection" gesture so it drives the same word → line → paragraph
75/// granularity ladder ([`tap_selection_granularity`]) as a rapid multi-tap.
76///
77/// Inputs:
78/// * `raw_tap_count` — the time-and-slop-gated multi-tap count from
79/// [`classify_tap_count`] (2+ means a genuine rapid multi-tap in progress);
80/// * `previous_count` — the effective count the *previous* press resolved to
81/// (the field remembers it as its click count);
82/// * `tap_in_selection` — the press landed inside the current, non-collapsed
83/// selection;
84/// * `repeat_in_place` — the press landed within the multi-tap slop of the
85/// previous press, **independent of timing** (the same spot, tapped again).
86///
87/// Behavior:
88/// * a rapid multi-tap (`raw_tap_count >= 2`) uses its own running count, so
89/// double→word, triple→line, … keep working exactly as before;
90/// * a lone tap inside a selection selects the word under the finger, and each
91/// further tap at the *same spot* climbs the ladder (word → line → paragraph →
92/// word …) even when it arrives slowly (the multi-tap timeout has lapsed) —
93/// users tap-then-look-then-tap, so the growth is keyed on location, not time;
94/// * a lone tap at a *new* spot inside the selection re-grabs that word (resets
95/// to word); and
96/// * a lone tap outside any selection is left as-is (a single tap → caret).
97pub fn resolve_selection_tap_count(
98 raw_tap_count: u8,
99 previous_count: u8,
100 tap_in_selection: bool,
101 repeat_in_place: bool,
102) -> u8 {
103 if raw_tap_count >= 2 {
104 raw_tap_count
105 } else if tap_in_selection {
106 if repeat_in_place {
107 previous_count.max(1).saturating_add(1)
108 } else {
109 2
110 }
111 } else {
112 raw_tap_count
113 }
114}
115
116/// Maps a 1-based tap count to the granularity it selects.
117///
118/// A single tap places the caret; two taps select the word, three the line,
119/// four the paragraph, and every further tap cycles back through
120/// word → line → paragraph so a resting finger keeps toggling between the three
121/// range granularities (matching desktop editors and iOS).
122pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
123 match tap_count {
124 0 | 1 => SelectionGranularity::Caret,
125 n => match (n - 2) % 3 {
126 0 => SelectionGranularity::Word,
127 1 => SelectionGranularity::Line,
128 _ => SelectionGranularity::Paragraph,
129 },
130 }
131}
132
133/// The unit of text at `pos` that `granularity` selects, as a byte range
134/// `[start, end)`; a caret is the empty range at `pos`.
135pub fn granularity_boundaries(
136 text: &str,
137 pos: usize,
138 granularity: SelectionGranularity,
139) -> (usize, usize) {
140 match granularity {
141 SelectionGranularity::Caret => (pos, pos),
142 SelectionGranularity::Word => crate::word_boundaries::find_word_boundaries(text, pos),
143 SelectionGranularity::Line => find_line_boundaries(text, pos),
144 SelectionGranularity::Paragraph => find_paragraph_boundaries(text, pos),
145 }
146}
147
148/// What a press selected, kept while the pointer stays down so a drag grows
149/// the selection in the same unit: a double click then drag selects whole
150/// words, a triple click whole lines.
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub struct SelectionAnchor {
153 /// The unit the press selected.
154 pub granularity: SelectionGranularity,
155 /// Byte offset where the pressed unit starts.
156 pub start: usize,
157 /// Byte offset where the pressed unit ends.
158 pub end: usize,
159}
160
161impl SelectionAnchor {
162 /// The unit at `pos` that `granularity` selects, as a press there selects it.
163 pub fn at(text: &str, pos: usize, granularity: SelectionGranularity) -> Self {
164 let (start, end) = granularity_boundaries(text, pos, granularity);
165 Self {
166 granularity,
167 start,
168 end,
169 }
170 }
171
172 /// The selection a drag from this press to `pos` makes: the pressed unit
173 /// together with the unit under `pos`, anchored at the far side of the
174 /// pressed unit so the selection grows the way the pointer moves.
175 pub fn dragged_to(self, text: &str, pos: usize) -> TextRange {
176 let (unit_start, unit_end) = granularity_boundaries(text, pos, self.granularity);
177 if unit_start < self.start {
178 TextRange::new(self.end, unit_start)
179 } else {
180 TextRange::new(self.start, unit_end.max(self.end))
181 }
182 }
183}
184
185/// Returns the byte range `[start, end)` of the line containing `pos`, delimited
186/// by `\n` (the newline itself is excluded from the range).
187///
188/// Used for triple-tap line selection. Byte offsets always land on `char`
189/// boundaries because `\n` is a single-byte ASCII character.
190pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
191 let pos = pos.min(text.len());
192 let start = text[..pos].rfind('\n').map_or(0, |i| i + 1);
193 let end = text[pos..].find('\n').map_or(text.len(), |i| pos + i);
194 (start, end)
195}
196
197/// Returns the byte range `[start, end)` of the paragraph containing `pos`.
198///
199/// Paragraphs are delimited by blank lines — a run of two or more consecutive
200/// `\n` — so a fourth tap grows the selection from one line to the whole block
201/// of text around it. Text with no blank line is a single paragraph (the whole
202/// string). Byte offsets land on `char` boundaries because `\n` is single-byte
203/// ASCII. Unicode-aware: multi-byte characters inside the paragraph are spanned
204/// whole.
205pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
206 let pos = pos.min(text.len());
207 let start = text[..pos].rfind("\n\n").map_or(0, |i| {
208 let mut s = i + 1;
209 while text[s..].starts_with('\n') {
210 s += 1;
211 }
212 s
213 });
214 let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
215 (start.min(end), end)
216}
217
218/// Which visual line a caret/handle at a soft-wrap boundary belongs to. At a
219/// shared boundary byte (the end of one wrapped visual line IS the start of
220/// the next — mid-word wraps produce these) the offset alone is ambiguous:
221///
222/// * [`LineAffinity::Upstream`] anchors to the END of the upper line — the
223/// glyph a dragging finger means. Selection END and cursor handles, the
224/// drawn caret, and the loupe use this; without it a drag along a wrapped
225/// line's right edge snaps the handle one line DOWN and to the left edge.
226/// * [`LineAffinity::Downstream`] anchors to the START of the lower line —
227/// where the first selected glyph actually renders. Selection START handles
228/// and highlight geometry use this.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum LineAffinity {
231 Upstream,
232 Downstream,
233}
234
235/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
236/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
237/// sits on.
238///
239/// The caret belongs to the last visual line whose start is at or before
240/// `offset`, except at a shared soft-wrap boundary where `affinity` decides
241/// (see [`LineAffinity`]):
242/// * a caret in the middle of a visual line resolves to that line;
243/// * a caret at the very end of the text sits on the last visual line.
244///
245/// This is the wrap-aware replacement for counting logical `\n` lines: without
246/// it, a caret on a wrapped line's second visual line is drawn on the first (and
247/// its x runs off the right edge), even though typing and the magnifier place it
248/// correctly. Returns `(0, 0)` when there are no ranges.
249pub fn caret_visual_line(
250 ranges: &[std::ops::Range<usize>],
251 offset: usize,
252 affinity: LineAffinity,
253) -> (usize, usize) {
254 let mut result = (0usize, 0usize);
255 for (index, range) in ranges.iter().enumerate() {
256 if range.start <= offset {
257 if affinity == LineAffinity::Upstream
258 && index > 0
259 && range.start == offset
260 && ranges[index - 1].end == offset
261 && ranges[index - 1].start < offset
262 {
263 break;
264 }
265 result = (index, range.start);
266 } else {
267 break;
268 }
269 }
270 result
271}
272
273/// Downward travel that follows with the original finger-to-handle offset
274/// before the visibility drift starts.
275pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
276/// Additional downward travel over which the handle moves into full view.
277pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
278/// Extra clearance (dp) below the handle dot once fully visible above the
279/// finger.
280pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
281
282/// The drift target: bias placing the finger just below the handle dot
283/// (tip + dot + clearance), so the whole lollipop stays visible above it.
284pub fn grab_bias_full_view() -> f32 {
285 -(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
286}
287
288/// Finger-to-handle relationship for one drag. The first phase preserves the
289/// captured offset exactly, the second shifts the handle above the finger,
290/// and the third preserves that final offset exactly. Progress is based on
291/// the furthest displacement from the grab, so event cadence and small
292/// reversals cannot change the result.
293#[derive(Clone, Copy, Debug, PartialEq)]
294pub struct HandleGrabOffset {
295 initial_bias: f32,
296 bias: f32,
297 start_y: f32,
298 furthest_y: f32,
299 drift_progress: f32,
300 drifts: bool,
301}
302
303impl HandleGrabOffset {
304 pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
305 Self::begin_for(handle_tip_y, finger_y, true)
306 }
307
308 pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
309 let initial_bias = handle_tip_y - finger_y;
310 Self {
311 initial_bias,
312 bias: initial_bias,
313 start_y: finger_y,
314 furthest_y: finger_y,
315 drift_progress: 0.0,
316 drifts,
317 }
318 }
319
320 pub fn track(&mut self, finger_y: f32) -> f32 {
321 if !self.drifts {
322 self.bias = self.initial_bias;
323 return self.bias;
324 }
325 self.furthest_y = self.furthest_y.max(finger_y);
326 let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
327 let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
328 self.drift_progress = t * t * (3.0 - 2.0 * t);
329 let full_view = self.initial_bias.min(grab_bias_full_view());
330 self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
331 self.bias
332 }
333
334 pub fn bias(&self) -> f32 {
335 self.bias
336 }
337
338 pub fn drift_progress(&self) -> f32 {
339 self.drift_progress
340 }
341}
342
343/// Which selection handle a lollipop represents.
344#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
345pub enum HandleKind {
346 /// The cursor handle shown for a collapsed selection: the caret stem with a
347 /// round grab dot hanging below the line (like the end handle).
348 Cursor,
349 /// The start (leftmost) selection handle: dot ON TOP of the line, stem
350 /// spanning the line box below it.
351 SelectionStart,
352 /// The end (rightmost) selection handle: stem spanning the line box, dot
353 /// hanging BELOW it.
354 SelectionEnd,
355}
356
357/// Radius of a selection/cursor handle dot in dp (the reference dot is
358/// 16.2 physical px at 3x ≈ a 16 dp circle).
359pub const HANDLE_RADIUS: f32 = 8.0;
360
361/// Width of the handle stem in dp (measured 6 px at 3x = 2 dp — the same
362/// weight as the caret).
363pub const HANDLE_STEM_WIDTH: f32 = 2.0;
364
365/// How far the dot dips INTO the line box (dp): the reference start dot's
366/// bottom sits ~5 px (1.7 dp) below the line-box top, the end dot's top ~6 px
367/// above the line-box bottom, so dot and stem read as one continuous shape.
368pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
369
370/// SVG path data for a handle lollipop at a text edge.
371///
372/// `anchor_x` is the text edge (caret / selection endpoint) x; the line box
373/// spans `line_top .. line_bottom`. The stem (width
374/// [`HANDLE_STEM_WIDTH`]) always spans the line box, centered on `anchor_x`;
375/// the dot (radius `radius`) sits tangent just outside the line box — above it
376/// for [`SelectionStart`](HandleKind::SelectionStart), below it for
377/// [`SelectionEnd`](HandleKind::SelectionEnd) and
378/// [`Cursor`](HandleKind::Cursor) — overlapping the box edge by
379/// [`HANDLE_DOT_LINE_OVERLAP`] so the two read as one shape.
380pub fn handle_path_data(
381 kind: HandleKind,
382 anchor_x: f32,
383 line_top: f32,
384 line_bottom: f32,
385 radius: f32,
386) -> String {
387 let r = radius.max(0.0);
388 let half_stem = HANDLE_STEM_WIDTH * 0.5;
389 let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
390 let stem = |top: f32, bottom: f32| {
391 format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
392 };
393 let dot = |cy: f32| {
394 format!(
395 "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
396 x0 = anchor_x - r,
397 x1 = anchor_x + r,
398 )
399 };
400 match kind {
401 HandleKind::SelectionStart => {
402 let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
403 format!("{} {}", stem(line_top, line_bottom), dot(cy))
404 }
405 HandleKind::SelectionEnd | HandleKind::Cursor => {
406 let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
407 format!("{} {}", stem(line_top, line_bottom), dot(cy))
408 }
409 }
410}
411
412/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
413/// its touch target, matching Android's generous handle hit area. A bare
414/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
415/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
416/// slop the press falls through to the field below and places a caret, which
417/// collapses the selection. The slop is applied to the sides and BELOW the tip
418/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
419/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
420/// so a double-tap still reaches the field to escalate into a word selection.
421pub const HANDLE_GRAB_SLOP: f32 = 24.0;
422
423/// Computes the selection `(min, max)` that results from dragging one handle to
424/// a new text `offset`, keeping the opposite (fixed) edge anchored.
425///
426/// Dragging never lets the two edges cross: a dragged start clamps to just
427/// before the fixed end, and a dragged end clamps to just after the fixed
428/// start, so the selection keeps at least one selected unit.
429pub fn selection_after_handle_drag(
430 dragged: HandleKind,
431 fixed_edge: usize,
432 dragged_offset: usize,
433 text_len: usize,
434) -> (usize, usize) {
435 let fixed = fixed_edge.min(text_len);
436 let dragged_offset = dragged_offset.min(text_len);
437 match dragged {
438 HandleKind::SelectionStart => {
439 let start = dragged_offset.min(fixed.saturating_sub(1));
440 (start, fixed)
441 }
442 HandleKind::SelectionEnd => {
443 let end = dragged_offset.max(fixed + 1).min(text_len);
444 (fixed, end)
445 }
446 HandleKind::Cursor => (dragged_offset, dragged_offset),
447 }
448}
449
450#[cfg(test)]
451#[path = "tests/text_selection_tests.rs"]
452mod tests;