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}