Skip to main content

cranpose_ui/widgets/
selection_handle.rs

1//! Draggable text-selection handles (accent lollipops) drawn in the top-level
2//! overlay.
3//!
4//! A [`SelectionHandle`] renders one of the lollipop shapes from
5//! [`crate::text_selection`] at a text endpoint (the caret, or a selection
6//! start/end) and lets a finger drag it: a 2 dp stem spanning the line box
7//! with a 16 dp dot tangent just outside it — above the line for the start
8//! handle, below it for the end and cursor handles. It is composed inside a
9//! [`Popup`] so it draws above the field and is not clipped when it hangs
10//! outside the line. Positioning and drag→text-offset mapping are the
11//! caller's job (see `BasicTextField`); this widget only draws the lollipop
12//! and reports the window-space position of an in-progress drag.
13
14#![allow(non_snake_case)]
15
16use std::cell::Cell;
17use std::rc::Rc;
18
19use crate::composable;
20use crate::modifier::Modifier;
21use crate::text_selection::{
22    handle_path_data, HandleKind, HANDLE_DOT_LINE_OVERLAP, HANDLE_GRAB_SLOP, HANDLE_STEM_WIDTH,
23};
24use crate::widgets::box_widget::{Box, BoxSpec};
25use crate::widgets::popup::Popup;
26use crate::PointerInputScope;
27use cranpose_core::remember;
28use cranpose_foundation::{PointerEvent, PointerEventKind};
29use cranpose_ui_graphics::{Brush, Color, DrawScope, Point, Rect, Size, VectorPath};
30
31/// How long (ms) the finger must rest on a handle — without moving beyond
32/// [`HANDLE_LONG_PRESS_SLOP_PX`] — before it counts as a long-press. Matches
33/// Android's ~500ms long-press timeout.
34pub(crate) const HANDLE_LONG_PRESS_TIMEOUT_MS: i64 = 500;
35/// Movement (px) tolerated during a long-press before it is treated as a drag
36/// instead. Keeps a resting finger a long-press even with minor jitter.
37pub(crate) const HANDLE_LONG_PRESS_SLOP_PX: f32 = 12.0;
38/// Movement (px) tolerated between a handle's press and release for the gesture
39/// to count as a tap (a quick press→release that did not drag the handle). A tap
40/// on the collapsed cursor handle opens its action popup (Paste / Select all /
41/// Undo / Redo).
42pub(crate) const HANDLE_TAP_SLOP_PX: f32 = 12.0;
43
44/// Geometry of a handle's drawable lollipop: the box it occupies (which
45/// doubles as its finger grab region) and where the anchor — the text edge at
46/// the line BOTTOM — sits inside that box, so the caller can anchor the box at
47/// `tip_endpoint - tip_in_box` to land the stem on the text edge.
48struct HandleShape {
49    /// SVG path of the lollipop in the box's local coordinates.
50    path_data: String,
51    /// The full box size including the finger grab slop. The box's own pointer
52    /// input is the handle's grab region, so this must be finger-sized.
53    box_size: Size,
54    /// The anchor position (text edge at the line bottom) within the box.
55    tip_in_box: Point,
56}
57
58/// Computes the lollipop path, box size and anchor offset for a handle,
59/// expanding the box by a finger-sized grab slop so the handle is easy to
60/// grab. `line_height` is the line box the stem spans, ending at the anchor
61/// (the line bottom).
62///
63/// The box is the handle's grab region (its `Box` carries the drag pointer
64/// input) and the slop rules are per kind:
65///
66/// * the CURSOR handle covers only its dot below the line (sides/below slop,
67///   nothing above the line bottom). Its stem is the field's own caret, and
68///   any grab region over the glyph line would swallow the second tap of a
69///   double-tap (which must reach the field to escalate into a word
70///   selection) — the exact regression this shape used to pin as a teardrop;
71/// * the START handle covers its dot above the line (slop above/sides) plus
72///   the stem column across the line box, with no slop below the line bottom
73///   (the next line's glyphs belong to the field);
74/// * the END handle mirrors it: the stem column from the line top (no slop
75///   above — the line's own glyphs stay tappable) down over its dot with
76///   slop below.
77///
78/// Grabbing an edge ON the line (the stem column) is what the reference does:
79/// a drag starting there rides the handle with the loupe up; a drag on the
80/// dot below drags without the loupe.
81fn handle_shape(kind: HandleKind, radius: f32, line_height: f32) -> HandleShape {
82    let slop = HANDLE_GRAB_SLOP.max(0.0);
83    let line_height = line_height.max(1.0);
84    let half_width = radius.max(HANDLE_STEM_WIDTH * 0.5);
85    // Vertical extent of the drawable relative to the anchor (line bottom).
86    let (draw_top, draw_bottom) = match kind {
87        // Dot above the line top (dipping HANDLE_DOT_LINE_OVERLAP into it).
88        HandleKind::SelectionStart => (-line_height - 2.0 * radius + HANDLE_DOT_LINE_OVERLAP, 0.0),
89        // Stem across the line, dot hanging below.
90        HandleKind::SelectionEnd => (-line_height, 2.0 * radius - HANDLE_DOT_LINE_OVERLAP),
91        // Dot below the line only (the field's caret is the stem).
92        HandleKind::Cursor => (0.0, 2.0 * radius - HANDLE_DOT_LINE_OVERLAP),
93    };
94    // Grab slop: sides always; above only where the handle's dot is above
95    // (start), below only where it hangs below (end/cursor).
96    let (slop_above, slop_below) = match kind {
97        HandleKind::SelectionStart => (slop, 0.0),
98        HandleKind::SelectionEnd | HandleKind::Cursor => (0.0, slop),
99    };
100    let box_top = draw_top - slop_above;
101    let box_bottom = draw_bottom + slop_below;
102    let tip_in_box = Point {
103        x: half_width + slop,
104        y: -box_top,
105    };
106    let box_size = Size {
107        width: 2.0 * half_width + 2.0 * slop,
108        height: box_bottom - box_top,
109    };
110    // The path in box-local coordinates. The cursor handle draws only its dot
111    // (its stem is the field's caret); start/end draw stem + dot.
112    let (line_top_local, line_bottom_local) = (tip_in_box.y - line_height, tip_in_box.y);
113    let path_data = if kind == HandleKind::Cursor {
114        // Dot tangent below the line bottom, overlap folded in.
115        let cy = line_bottom_local + radius - HANDLE_DOT_LINE_OVERLAP;
116        format!(
117            "M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
118            x0 = tip_in_box.x - radius,
119            x1 = tip_in_box.x + radius,
120            r = radius,
121        )
122    } else {
123        handle_path_data(
124            kind,
125            tip_in_box.x,
126            line_top_local,
127            line_bottom_local,
128            radius,
129        )
130    };
131    HandleShape {
132        path_data,
133        box_size,
134        tip_in_box,
135    }
136}
137
138/// The window-space axis-aligned grab region for a handle whose anchor (text
139/// edge at the line bottom) sits at `tip` (window coords). This is exactly the
140/// region covered by the handle's `Box` pointer input, expressed in window
141/// coordinates, so a touch-DOWN within it grabs the handle (the overlay
142/// `Popup` is hit-tested above the field, so a grab always wins over the
143/// field's caret placement).
144///
145/// Exposed so the arbitration can be asserted in tests without a full
146/// compose/layout/hit-test round-trip.
147#[cfg(test)]
148pub(crate) fn handle_grab_rect(
149    kind: HandleKind,
150    tip: Point,
151    radius: f32,
152    line_height: f32,
153) -> Rect {
154    let shape = handle_shape(kind, radius, line_height);
155    Rect {
156        x: tip.x - shape.tip_in_box.x,
157        y: tip.y - shape.tip_in_box.y,
158        width: shape.box_size.width,
159        height: shape.box_size.height,
160    }
161}
162
163/// Draw-phase glide state for a handle: position/velocity of the drawn
164/// lollipop trailing its true anchor, integrated on real time per redraw.
165#[derive(Clone, Copy)]
166struct GlideState {
167    x: f32,
168    y: f32,
169    vx: f32,
170    vy: f32,
171    last_nanos: u64,
172}
173
174fn glide_clock_nanos() -> u64 {
175    use std::sync::OnceLock;
176    use web_time::Instant;
177    static EPOCH: OnceLock<Instant> = OnceLock::new();
178    EPOCH.get_or_init(Instant::now).elapsed().as_nanos() as u64
179}
180
181/// A finger-draggable selection/cursor handle rendered in the overlay.
182///
183/// * `kind` — which lollipop to draw (cursor dot, selection start/end).
184/// * `tip` — window-space position of the text edge at the line BOTTOM (the
185///   caret/selection endpoint).
186/// * `line_height` — the line box the stem spans (up from `tip`).
187/// * `radius` / `color` — dot radius and accent fill.
188/// * `on_drag` — invoked with the current drag position (window space) on every
189///   pointer down/move so the field can map it to a text offset and move the
190///   caret / extend the selection.
191/// * `on_drag_end` — invoked when the finger lifts, so the field can settle
192///   (e.g. show the contextual menu).
193/// * `on_long_press` — invoked once when the finger rests on the handle past the
194///   long-press timeout without dragging it, so the field can (re)open the
195///   contextual menu even when the selection range has not changed.
196/// * `on_tap` — invoked when the finger lifts after a quick press that did not
197///   drag the handle beyond [`HANDLE_TAP_SLOP_PX`] (and was not a long-press),
198///   so the collapsed cursor handle can open its action popup.
199#[allow(clippy::too_many_arguments)]
200#[composable]
201pub fn SelectionHandle(
202    kind: HandleKind,
203    tip: Point,
204    line_height: f32,
205    radius: f32,
206    color: Color,
207    on_drag: impl Fn(Point) + 'static,
208    on_drag_end: impl Fn() + 'static,
209    on_long_press: impl Fn() + 'static,
210    on_tap: impl Fn() + 'static,
211) {
212    let shape = handle_shape(kind, radius, line_height);
213    // The handle GLIDES between character positions (the reference handle
214    // never teleports char-to-char); a jump farther than ~1.5 line heights
215    // is a fresh placement (double-tap, new selection) and snaps. The
216    // spring lives ENTIRELY in the draw phase on a plain Cell — no
217    // animation-state writes from composition or effects: such writes at
218    // the composition boundary can swallow pending invalidations and
219    // freeze the field's press feed (the edit-menu slide regression).
220    let glide: Rc<Cell<GlideState>> = remember(|| {
221        Rc::new(Cell::new(GlideState {
222            x: f32::NAN,
223            y: f32::NAN,
224            vx: 0.0,
225            vy: 0.0,
226            last_nanos: 0,
227        }))
228    })
229    .with(Rc::clone);
230    {
231        let state = glide.get();
232        let snap_distance = (line_height * 1.5).max(24.0);
233        let jump = ((tip.x - state.x).powi(2) + (tip.y - state.y).powi(2)).sqrt();
234        if !state.x.is_finite() || !state.y.is_finite() || jump > snap_distance {
235            glide.set(GlideState {
236                x: tip.x,
237                y: tip.y,
238                vx: 0.0,
239                vy: 0.0,
240                last_nanos: 0,
241            });
242        }
243    }
244    // Snap the box to whole logical pixels: the stem is a 2dp bar whose
245    // box-local coordinates are integral, so a fractional anchor gives one
246    // handle a 7px stem and the other a 6px one at 3x (anti-aliased
247    // asymmetry the reference never shows).
248    let anchor = Rect {
249        x: (tip.x - shape.tip_in_box.x).round(),
250        y: (tip.y - shape.tip_in_box.y).round(),
251        width: 0.0,
252        height: 0.0,
253    };
254    let glide_tip = tip;
255    let glide_for_draw = Rc::clone(&glide);
256    let path_data = shape.path_data;
257    let box_size = shape.box_size;
258    let on_drag: Rc<dyn Fn(Point)> = Rc::new(on_drag);
259    let on_drag_end: Rc<dyn Fn()> = Rc::new(on_drag_end);
260    let on_long_press: Rc<dyn Fn()> = Rc::new(on_long_press);
261    let on_tap: Rc<dyn Fn()> = Rc::new(on_tap);
262
263    Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
264        let path_data = path_data.clone();
265        let on_drag = Rc::clone(&on_drag);
266        let on_drag_end = Rc::clone(&on_drag_end);
267        let on_long_press = Rc::clone(&on_long_press);
268        let on_tap = Rc::clone(&on_tap);
269        let glide_for_draw = Rc::clone(&glide_for_draw);
270        Box(
271            Modifier::empty()
272                .size(box_size)
273                .draw_behind(move |scope: &mut dyn DrawScope| {
274                    // The CURSOR handle never glides: its stem IS the
275                    // field's caret and the dot must move as one object
276                    // with it (the caret itself is state-driven).
277                    if kind == HandleKind::Cursor {
278                        if let Ok(path) = VectorPath::parse(&path_data) {
279                            scope.draw_vector_path(&path, Brush::solid(color));
280                        }
281                        return;
282                    }
283                    // Render-only glide: a critically damped spring toward
284                    // the true anchor, integrated on real dt per redraw.
285                    // The hit box stays exact at the anchor; only the drawn
286                    // lollipop trails.
287                    let mut state = glide_for_draw.get();
288                    let settled = state.x.is_finite()
289                        && (state.x - glide_tip.x).abs() < 0.25
290                        && (state.y - glide_tip.y).abs() < 0.25
291                        && state.vx.abs() < 2.0
292                        && state.vy.abs() < 2.0;
293                    if settled {
294                        // Byte-stable draw at rest: keep writing/moving here
295                        // and the retained overlay scene churns every frame,
296                        // resetting pointer routing mid-gesture (the edit
297                        // menu's slide feed regression).
298                        if state.last_nanos != 0 {
299                            state = GlideState {
300                                x: glide_tip.x,
301                                y: glide_tip.y,
302                                vx: 0.0,
303                                vy: 0.0,
304                                last_nanos: 0,
305                            };
306                            glide_for_draw.set(state);
307                        }
308                    } else {
309                        let now_nanos = glide_clock_nanos();
310                        let dt = if state.last_nanos == 0 {
311                            0.0
312                        } else {
313                            ((now_nanos - state.last_nanos) as f32 / 1.0e9).min(0.05)
314                        };
315                        state.last_nanos = now_nanos;
316                        if dt > 0.0 && state.x.is_finite() {
317                            // Closed-form critically damped step: exact for
318                            // any frame gap. The Euler step was unstable
319                            // past its bound (omega*dt > 1) and a long gap
320                            // overshot 4x — the user-reported one-frame
321                            // far jump that then retracted.
322                            let omega = 40.0f32;
323                            let decay = (-omega * dt).exp();
324                            let sx = state.x - glide_tip.x;
325                            let sy = state.y - glide_tip.y;
326                            let cx = state.vx + omega * sx;
327                            let cy = state.vy + omega * sy;
328                            state.x = glide_tip.x + (sx + cx * dt) * decay;
329                            state.y = glide_tip.y + (sy + cy * dt) * decay;
330                            state.vx = (state.vx - omega * cx * dt) * decay;
331                            state.vy = (state.vy - omega * cy * dt) * decay;
332                        }
333                        glide_for_draw.set(state);
334                        // Keep frames coming while the glide is in flight: a
335                        // stationary hold otherwise stops redraws and the
336                        // spring freezes mid-transition. Render-side only —
337                        // no composition state is touched.
338                        crate::request_render_invalidation();
339                    }
340                    let (dx, dy) = if state.x.is_finite() {
341                        (state.x - glide_tip.x, state.y - glide_tip.y)
342                    } else {
343                        (0.0, 0.0)
344                    };
345                    if let Ok(path) = VectorPath::parse(&path_data) {
346                        scope.draw_vector_path(&path.translated(dx, dy), Brush::solid(color));
347                    }
348                })
349                .then(selection_handle_pointer_input(
350                    kind,
351                    Rc::clone(&on_drag),
352                    Rc::clone(&on_drag_end),
353                    Rc::clone(&on_long_press),
354                    Rc::clone(&on_tap),
355                )),
356            BoxSpec::default(),
357            || {},
358        );
359    });
360}
361
362/// Builds the pointer-input modifier that drives a selection handle: it reports
363/// every drag position, the drag end (finger lift), and a long-press (finger
364/// held in place past [`HANDLE_LONG_PRESS_TIMEOUT_MS`]). Shared by
365/// [`SelectionHandle`] and exercised directly in tests so the gesture semantics
366/// stay pinned without a full compose/layout/hit-test round-trip.
367///
368/// The gesture task is keyed by the handle `kind`. This is load-bearing: when a
369/// field's selection collapses to a caret and then re-expands into a range, the
370/// composition reuses the single cursor handle's positional slot for the range's
371/// **start** handle. A constant key would keep the running gesture task (and the
372/// `on_drag` closure it captured) from the previous frame, so the start handle
373/// would still execute the *cursor* handle's caret-placement drag and COLLAPSE
374/// the selection on grab — while the end handle (a fresh slot) worked fine, the
375/// exact start-vs-end asymmetry users hit. Keying by `kind` restarts the task
376/// with the correct `on_drag` when the slot's kind changes.
377pub(crate) fn selection_handle_pointer_input(
378    kind: HandleKind,
379    on_drag: Rc<dyn Fn(Point)>,
380    on_drag_end: Rc<dyn Fn()>,
381    on_long_press: Rc<dyn Fn()>,
382    on_tap: Rc<dyn Fn()>,
383) -> Modifier {
384    Modifier::empty().pointer_input(kind, move |scope: PointerInputScope| {
385        let on_drag = Rc::clone(&on_drag);
386        let on_drag_end = Rc::clone(&on_drag_end);
387        let on_long_press = Rc::clone(&on_long_press);
388        let on_tap = Rc::clone(&on_tap);
389        async move {
390            scope
391                .await_pointer_event_scope(|await_scope| async move {
392                    // Track the initial press so a resting finger can be told
393                    // apart from a drag. `time_ms` is the platform input clock;
394                    // when it is unavailable the long-press simply never fires
395                    // (a drag/lift still settles the selection).
396                    let mut down_time: Option<i64> = None;
397                    let mut down_pos = Point { x: 0.0, y: 0.0 };
398                    let mut pressed = false;
399                    let mut long_press_fired = false;
400                    // Whether the finger has strayed beyond the tap slop since it
401                    // went down — a stray means a drag, not a tap.
402                    let mut dragged = false;
403                    loop {
404                        let event = await_scope.await_pointer_event().await;
405                        match event.kind {
406                            PointerEventKind::Down => {
407                                down_time = event.time_ms;
408                                down_pos = event.global_position;
409                                pressed = true;
410                                long_press_fired = false;
411                                dragged = false;
412                                on_drag(event.global_position);
413                                event.consume();
414                            }
415                            PointerEventKind::Move => {
416                                // Only a move of a PRESSED pointer drags the
417                                // handle. Hover moves (mouse passing over, the
418                                // synthesized hover dispatch after a release)
419                                // must not: treating them as drags moved the
420                                // caret under a hovering mouse and re-armed
421                                // the loupe right after a release.
422                                if !pressed {
423                                    continue;
424                                }
425                                if moved_beyond(down_pos, event.global_position, HANDLE_TAP_SLOP_PX)
426                                {
427                                    dragged = true;
428                                }
429                                on_drag(event.global_position);
430                                maybe_fire_long_press(
431                                    &event,
432                                    down_time,
433                                    down_pos,
434                                    &mut long_press_fired,
435                                    &on_long_press,
436                                );
437                                event.consume();
438                            }
439                            PointerEventKind::Up => {
440                                if !pressed {
441                                    continue;
442                                }
443                                maybe_fire_long_press(
444                                    &event,
445                                    down_time,
446                                    down_pos,
447                                    &mut long_press_fired,
448                                    &on_long_press,
449                                );
450                                if moved_beyond(down_pos, event.global_position, HANDLE_TAP_SLOP_PX)
451                                {
452                                    dragged = true;
453                                }
454                                pressed = false;
455                                on_drag_end();
456                                // A quick press→release that neither dragged the
457                                // handle nor became a long-press is a tap: open
458                                // the handle's action popup.
459                                if !dragged && !long_press_fired {
460                                    on_tap();
461                                }
462                                down_time = None;
463                                event.consume();
464                            }
465                            PointerEventKind::Cancel => {
466                                if pressed {
467                                    pressed = false;
468                                    on_drag_end();
469                                }
470                                down_time = None;
471                                event.consume();
472                            }
473                            _ => {}
474                        }
475                    }
476                })
477                .await;
478        }
479    })
480}
481
482/// Whether `now` is more than `slop` px from `origin` (used to tell a tap from a
483/// drag on a selection handle).
484fn moved_beyond(origin: Point, now: Point, slop: f32) -> bool {
485    let dx = now.x - origin.x;
486    let dy = now.y - origin.y;
487    dx * dx + dy * dy > slop * slop
488}
489
490/// Fires `on_long_press` at most once per press when the finger has rested on
491/// the handle for at least [`HANDLE_LONG_PRESS_TIMEOUT_MS`] without moving more
492/// than [`HANDLE_LONG_PRESS_SLOP_PX`] from where it went down (i.e. a hold, not
493/// a drag).
494fn maybe_fire_long_press(
495    event: &PointerEvent,
496    down_time: Option<i64>,
497    down_pos: Point,
498    long_press_fired: &mut bool,
499    on_long_press: &Rc<dyn Fn()>,
500) {
501    if *long_press_fired {
502        return;
503    }
504    let (Some(down_ms), Some(now_ms)) = (down_time, event.time_ms) else {
505        return;
506    };
507    if is_handle_long_press(down_ms, now_ms, down_pos, event.global_position) {
508        *long_press_fired = true;
509        on_long_press();
510    }
511}
512
513/// Pure predicate for a handle long-press: held long enough and moved little
514/// enough to be a rest rather than a drag.
515pub(crate) fn is_handle_long_press(
516    down_ms: i64,
517    now_ms: i64,
518    down_pos: Point,
519    now_pos: Point,
520) -> bool {
521    let dx = now_pos.x - down_pos.x;
522    let dy = now_pos.y - down_pos.y;
523    let moved = (dx * dx + dy * dy).sqrt();
524    now_ms - down_ms >= HANDLE_LONG_PRESS_TIMEOUT_MS && moved <= HANDLE_LONG_PRESS_SLOP_PX
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::text_selection::HANDLE_RADIUS;
531
532    const LINE_HEIGHT: f32 = 20.0;
533
534    /// Regression guard for double-tap-to-select-word. The cursor handle — the
535    /// one a single tap shows — must keep its whole touch box AT OR BELOW the
536    /// line bottom (its dot hangs under the line; its stem is the field's own
537    /// caret). Any grab region over the glyph line would swallow the second
538    /// tap of a double-tap (which must reach the field to escalate into a word
539    /// selection).
540    #[test]
541    fn cursor_handle_touch_box_sits_at_or_below_the_line_bottom() {
542        let shape = handle_shape(HandleKind::Cursor, HANDLE_RADIUS, LINE_HEIGHT);
543        // The box is anchored at `tip - tip_in_box`; `tip_in_box.y == 0` means
544        // its top edge coincides with the anchor (the caret line's bottom).
545        assert!(
546            shape.tip_in_box.y.abs() < 0.01,
547            "cursor handle anchor must sit at the top edge of its touch box \
548             (tip_in_box.y = {}), so it never overlaps the text line above",
549            shape.tip_in_box.y
550        );
551        // The dot still hangs below the anchor with room for a finger.
552        assert!(
553            shape.box_size.height >= 2.0 * HANDLE_RADIUS,
554            "cursor handle box must extend below the anchor so the dot is grabbable"
555        );
556        // And it draws ONLY the dot — the stem is the field's caret, which
557        // keeps blinking; a drawn stem would paint over it.
558        assert!(
559            !shape.path_data.contains('L'),
560            "cursor handle must draw only its dot (no stem rectangle): {}",
561            shape.path_data
562        );
563    }
564
565    /// The grab region must be finger-sized: at least a fingertip across so a
566    /// touch-DOWN aimed at the handle reliably lands inside it instead of
567    /// falling through to the field (which would collapse the selection). A
568    /// fingertip is ~24-32dp; the drawn dot alone (~2·radius) is far too
569    /// small, so the box must add a generous slop.
570    #[test]
571    fn grab_region_is_finger_sized() {
572        for kind in [
573            HandleKind::Cursor,
574            HandleKind::SelectionStart,
575            HandleKind::SelectionEnd,
576        ] {
577            let rect = handle_grab_rect(
578                kind,
579                Point { x: 100.0, y: 100.0 },
580                HANDLE_RADIUS,
581                LINE_HEIGHT,
582            );
583            assert!(
584                rect.width >= 48.0,
585                "{kind:?}: grab region must be at least a fingertip wide, got {}",
586                rect.width
587            );
588            assert!(
589                rect.height >= 32.0,
590                "{kind:?}: grab region must be at least a fingertip tall, got {}",
591                rect.height
592            );
593        }
594    }
595
596    /// The start and end grab regions are vertical mirror images of each other
597    /// about the line box: the start covers its dot ABOVE the line (plus the
598    /// stem column across the line), the end covers the stem column and its dot
599    /// BELOW — so neither handle is harder to grab than the other.
600    #[test]
601    fn start_and_end_grab_regions_mirror_about_the_line() {
602        let tip = Point { x: 100.0, y: 100.0 };
603        let line_top = tip.y - LINE_HEIGHT;
604        let start = handle_grab_rect(HandleKind::SelectionStart, tip, HANDLE_RADIUS, LINE_HEIGHT);
605        let end = handle_grab_rect(HandleKind::SelectionEnd, tip, HANDLE_RADIUS, LINE_HEIGHT);
606
607        // Same-size boxes.
608        assert!(
609            (start.width - end.width).abs() < 0.01 && (start.height - end.height).abs() < 0.01,
610            "start {start:?} and end {end:?} grab boxes must be the same size"
611        );
612        // Start reaches above the line top as far as end reaches below the
613        // line bottom (dot + slop), and each stops at the opposite line edge.
614        let start_above = line_top - start.y;
615        let end_below = (end.y + end.height) - tip.y;
616        assert!(
617            (start_above - end_below).abs() < 0.01,
618            "start reach above the line ({start_above}) must equal end reach below ({end_below})"
619        );
620        assert!(
621            ((start.y + start.height) - tip.y).abs() < 0.01,
622            "start grab box must stop at the line bottom (no slop below)"
623        );
624        assert!(
625            (end.y - line_top).abs() < 0.01,
626            "end grab box must start at the line top (no slop above)"
627        );
628    }
629
630    /// Per-kind grab arbitration: a press on a handle's dot (or a finger-width
631    /// beside it) grabs the handle; a press one line AWAY from the handle's
632    /// extent — the neighbouring glyph line — must fall through to the field
633    /// so taps/double-taps there keep working. Start and end handles also
634    /// cover their stem column ON the line (the reference grabs an edge on the
635    /// line and rides it with the loupe up); the cursor handle never covers
636    /// the line (double-tap protection).
637    #[test]
638    fn grab_regions_cover_the_dot_and_stem_but_not_neighbouring_lines() {
639        let tip = Point { x: 100.0, y: 100.0 };
640        let line_top = tip.y - LINE_HEIGHT;
641        let contains = |r: Rect, x: f32, y: f32| {
642            x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
643        };
644
645        // Cursor: dot below the line is grabbable; the line above is not.
646        let cursor = handle_grab_rect(HandleKind::Cursor, tip, HANDLE_RADIUS, LINE_HEIGHT);
647        assert!(contains(cursor, tip.x - 16.0, tip.y + 12.0));
648        assert!(contains(cursor, tip.x + 16.0, tip.y + 12.0));
649        assert!(
650            !contains(cursor, tip.x, tip.y - 2.0),
651            "cursor: a press on the glyph line belongs to the field"
652        );
653
654        // Start: dot above the line and the stem column on the line grab it;
655        // the line BELOW (next line's glyphs) does not.
656        let start = handle_grab_rect(HandleKind::SelectionStart, tip, HANDLE_RADIUS, LINE_HEIGHT);
657        assert!(
658            contains(start, tip.x, line_top - HANDLE_RADIUS),
659            "start: a press on the dot above the line must grab the handle"
660        );
661        assert!(
662            contains(start, tip.x, tip.y - LINE_HEIGHT * 0.5),
663            "start: a press on the stem column (on the line) must grab the handle"
664        );
665        assert!(
666            !contains(start, tip.x, tip.y + 4.0),
667            "start: a press below the line belongs to the field"
668        );
669
670        // End: stem column and dot below grab it; the line ABOVE does not.
671        let end = handle_grab_rect(HandleKind::SelectionEnd, tip, HANDLE_RADIUS, LINE_HEIGHT);
672        assert!(
673            contains(end, tip.x, tip.y + HANDLE_RADIUS),
674            "end: a press on the dot below the line must grab the handle"
675        );
676        assert!(
677            contains(end, tip.x, tip.y - LINE_HEIGHT * 0.5),
678            "end: a press on the stem column (on the line) must grab the handle"
679        );
680        assert!(
681            !contains(end, tip.x, line_top - 4.0),
682            "end: a press above the line belongs to the field"
683        );
684    }
685}