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_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    /// Anchors the offset to the end of the upper line.
232    Upstream,
233    /// Anchors the offset to the start of the lower line.
234    Downstream,
235}
236
237/// Given the source byte ranges of the **visual** (wrapped) lines and a caret
238/// byte `offset`, returns the `(visual_line_index, line_start_byte)` the caret
239/// sits on.
240///
241/// The caret belongs to the last visual line whose start is at or before
242/// `offset`, except at a shared soft-wrap boundary where `affinity` decides
243/// (see [`LineAffinity`]):
244/// * a caret in the middle of a visual line resolves to that line;
245/// * a caret at the very end of the text sits on the last visual line.
246///
247/// This is the wrap-aware replacement for counting logical `\n` lines: without
248/// it, a caret on a wrapped line's second visual line is drawn on the first (and
249/// its x runs off the right edge), even though typing and the magnifier place it
250/// correctly. Returns `(0, 0)` when there are no ranges.
251pub fn caret_visual_line(
252    ranges: &[std::ops::Range<usize>],
253    offset: usize,
254    affinity: LineAffinity,
255) -> (usize, usize) {
256    let mut result = (0usize, 0usize);
257    for (index, range) in ranges.iter().enumerate() {
258        if range.start <= offset {
259            if affinity == LineAffinity::Upstream
260                && index > 0
261                && range.start == offset
262                && ranges[index - 1].end == offset
263                && ranges[index - 1].start < offset
264            {
265                break;
266            }
267            result = (index, range.start);
268        } else {
269            break;
270        }
271    }
272    result
273}
274
275/// Downward travel that follows with the original finger-to-handle offset
276/// before the visibility drift starts.
277pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
278/// Additional downward travel over which the handle moves into full view.
279pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
280/// Extra clearance (dp) below the handle dot once fully visible above the
281/// finger.
282pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
283
284/// The drift target: bias placing the finger just below the handle dot
285/// (tip + dot + clearance), so the whole lollipop stays visible above it.
286pub fn grab_bias_full_view() -> f32 {
287    -(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
288}
289
290/// Finger-to-handle relationship for one drag. The first phase preserves the
291/// captured offset exactly, the second shifts the handle above the finger,
292/// and the third preserves that final offset exactly. Progress is based on
293/// the furthest displacement from the grab, so event cadence and small
294/// reversals cannot change the result.
295#[derive(Clone, Copy, Debug, PartialEq)]
296pub struct HandleGrabOffset {
297    initial_bias: f32,
298    bias: f32,
299    start_y: f32,
300    furthest_y: f32,
301    drift_progress: f32,
302    drifts: bool,
303}
304
305impl HandleGrabOffset {
306    /// Starts a drag from a handle whose tip is at `handle_tip_y` while the pointer is at
307    /// `finger_y`, both in the same vertical coordinates in logical pixels. The handle drifts into
308    /// view as the pointer moves down.
309    pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
310        Self::begin_for(handle_tip_y, finger_y, true)
311    }
312
313    /// Like [`Self::begin`], but with `drifts` set to false the handle keeps the starting offset
314    /// for the whole drag.
315    pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
316        let initial_bias = handle_tip_y - finger_y;
317        Self {
318            initial_bias,
319            bias: initial_bias,
320            start_y: finger_y,
321            furthest_y: finger_y,
322            drift_progress: 0.0,
323            drifts,
324        }
325    }
326
327    /// Takes the pointer's current `finger_y` and returns the updated offset from the pointer to
328    /// the handle tip.
329    pub fn track(&mut self, finger_y: f32) -> f32 {
330        if !self.drifts {
331            self.bias = self.initial_bias;
332            return self.bias;
333        }
334        self.furthest_y = self.furthest_y.max(finger_y);
335        let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
336        let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
337        self.drift_progress = t * t * (3.0 - 2.0 * t);
338        let full_view = self.initial_bias.min(grab_bias_full_view());
339        self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
340        self.bias
341    }
342
343    /// Returns the current offset from the pointer to the handle tip, in logical pixels.
344    pub fn bias(&self) -> f32 {
345        self.bias
346    }
347
348    /// Returns how far the offset has moved from its starting value to the fully visible one, from
349    /// 0.0 to 1.0.
350    pub fn drift_progress(&self) -> f32 {
351        self.drift_progress
352    }
353}
354
355/// Which selection handle a lollipop represents.
356#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
357pub enum HandleKind {
358    /// The cursor handle shown for a collapsed selection: the caret stem with a
359    /// round grab dot hanging below the line (like the end handle).
360    Cursor,
361    /// The start (leftmost) selection handle: dot ON TOP of the line, stem
362    /// spanning the line box below it.
363    SelectionStart,
364    /// The end (rightmost) selection handle: stem spanning the line box, dot
365    /// hanging BELOW it.
366    SelectionEnd,
367}
368
369/// Radius of a selection/cursor handle dot in dp (the reference dot is
370/// 16.2 physical px at 3x ≈ a 16 dp circle).
371pub const HANDLE_RADIUS: f32 = 8.0;
372
373/// Width of the handle stem in dp (measured 6 px at 3x = 2 dp — the same
374/// weight as the caret).
375pub const HANDLE_STEM_WIDTH: f32 = 2.0;
376
377/// How far the dot dips INTO the line box (dp): the reference start dot's
378/// bottom sits ~5 px (1.7 dp) below the line-box top, the end dot's top ~6 px
379/// above the line-box bottom, so dot and stem read as one continuous shape.
380pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
381
382/// SVG path data for a handle lollipop at a text edge.
383///
384/// `anchor_x` is the text edge (caret / selection endpoint) x; the line box
385/// spans `line_top .. line_bottom`. The stem (width
386/// [`HANDLE_STEM_WIDTH`]) always spans the line box, centered on `anchor_x`;
387/// the dot (radius `radius`) sits tangent just outside the line box — above it
388/// for [`SelectionStart`](HandleKind::SelectionStart), below it for
389/// [`SelectionEnd`](HandleKind::SelectionEnd) and
390/// [`Cursor`](HandleKind::Cursor) — overlapping the box edge by
391/// [`HANDLE_DOT_LINE_OVERLAP`] so the two read as one shape.
392pub fn handle_path_data(
393    kind: HandleKind,
394    anchor_x: f32,
395    line_top: f32,
396    line_bottom: f32,
397    radius: f32,
398) -> String {
399    let r = radius.max(0.0);
400    let half_stem = HANDLE_STEM_WIDTH * 0.5;
401    let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
402    let stem = |top: f32, bottom: f32| {
403        format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
404    };
405    let dot = |cy: f32| {
406        format!(
407            "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
408            x0 = anchor_x - r,
409            x1 = anchor_x + r,
410        )
411    };
412    match kind {
413        HandleKind::SelectionStart => {
414            let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
415            format!("{} {}", stem(line_top, line_bottom), dot(cy))
416        }
417        HandleKind::SelectionEnd | HandleKind::Cursor => {
418            let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
419            format!("{} {}", stem(line_top, line_bottom), dot(cy))
420        }
421    }
422}
423
424/// Finger-sized grab slop (px) added around a handle's drawn teardrop to enlarge
425/// its touch target, matching Android's generous handle hit area. A bare
426/// teardrop (~2·[`HANDLE_RADIUS`] across) is far smaller than a fingertip, so a
427/// touch-DOWN aimed at a handle routinely lands a few px off it; without this
428/// slop the press falls through to the field below and places a caret, which
429/// collapses the selection. The slop is applied to the sides and BELOW the tip
430/// (where the bulb and the grabbing finger sit) but never ABOVE the tip — see
431/// [`crate::widgets::selection_handle`], which keeps the box off the glyph line
432/// so a double-tap still reaches the field to escalate into a word selection.
433pub const HANDLE_GRAB_SLOP: f32 = 24.0;
434
435/// Computes the selection `(min, max)` that results from dragging one handle to
436/// a new text `offset`, keeping the opposite (fixed) edge anchored.
437///
438/// Dragging never lets the two edges cross: a dragged start clamps to just
439/// before the fixed end, and a dragged end clamps to just after the fixed
440/// start, so the selection keeps at least one selected unit.
441pub fn selection_after_handle_drag(
442    dragged: HandleKind,
443    fixed_edge: usize,
444    dragged_offset: usize,
445    text_len: usize,
446) -> (usize, usize) {
447    let fixed = fixed_edge.min(text_len);
448    let dragged_offset = dragged_offset.min(text_len);
449    match dragged {
450        HandleKind::SelectionStart => {
451            let start = dragged_offset.min(fixed.saturating_sub(1));
452            (start, fixed)
453        }
454        HandleKind::SelectionEnd => {
455            let end = dragged_offset.max(fixed + 1).min(text_len);
456            (fixed, end)
457        }
458        HandleKind::Cursor => (dragged_offset, dragged_offset),
459    }
460}
461
462#[cfg(test)]
463#[path = "tests/text_selection_tests.rs"]
464mod tests;