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