frust_widgets/gesture.rs
1//! The `GestureDetector` widget: a transparent wrapper that recognises a tap and
2//! a long-press on its child and forwards raw events through.
3//!
4//! [`GestureDetector`] wraps an arbitrary child view and fires either an
5//! `on_tap` closure (a `Down`→`Up` that stays within [`TOUCH_SLOP`]) or an
6//! `on_long_press` closure (a press held past [`LONG_PRESS_MS`] without moving
7//! past the slop). Moving past the slop disarms both (it became a drag). The
8//! wrapper is transparent — every event is still forwarded to the child, so
9//! interactive descendants keep working. v1 recognises **tap + long-press**;
10//! double-tap is deferred, and lacking an input-kind flag on events the slop is
11//! [`TOUCH_SLOP`] uniformly.
12//! Two-finger pinch lives in [`crate::pinch`] ([`crate::pinch::pinch_detector`]).
13//!
14//! # Long-press firing semantics
15//!
16//! Timing is measured across **paints**, not events: only the paint pass carries
17//! a clock ([`PaintCtx::frame_time`]). A press records `press_start` on its first
18//! paint and marks itself *elapsed* on a later paint once
19//! `frame_time - press_start >= LONG_PRESS_MS`.
20//!
21//! **The callback never fires during paint itself** (there is no `&mut
22//! State`/`EventCtx` there). Instead, the paint that observes the threshold
23//! crossed calls [`frust_core::mark_pending_result_flush`] — the same mechanism
24//! [`crate::nav::navigator`] uses to run a queued pop-result callback from a
25//! state-free `View::rebuild` pass — and [`PaintCtx::request_frame`]s a follow-up
26//! frame. Every shipping shell (desktop/Android/iOS) runs `RenderRoot::rebuild`
27//! before `RenderRoot::paint` on every frame it drives, so the very next frame's
28//! rebuild drains that mark, dispatches an `InputEvent::Housekeeping` broadcast
29//! carrying a real `EventCtx`, and this widget fires `on_long_press` from there —
30//! **at the threshold, in wall time**, whether or not the finger ever moves again.
31//!
32//! A pointer-delivered fire's `EventCtx::request_redraw` rides the shell's own
33//! `RenderRoot::event` call back out; a Housekeeping-delivered one reaches the
34//! shell only because `RenderRoot::rebuild` propagates the broadcast's
35//! `EventOutcome` into `ChangeFlags::PAINT` and the deferred frame request
36//! (`docs/CORE_ARCHITECTURE.md`'s event-routing data flow) — so a fire whose
37//! consumer mutates nothing the view diff can see still repaints on both a
38//! frame-gated mobile loop and a dirty-driven desktop one.
39//!
40//! Two delivery paths race for the same fire, and whichever reaches the widget
41//! first wins — the `Recognizer::Fired` transition makes the other one a no-op:
42//!
43//! - the **Housekeeping broadcast** above, ordinarily one frame after the
44//! threshold, needing no pointer event at all; or
45//! - a **live pointer event** that arrives first — `Up` (a hold that exceeded
46//! the threshold releases as a long-press instead of a tap) or an in-slop
47//! `Move` (so a context menu can still open the instant a held finger jitters,
48//! without waiting out the extra frame).
49//!
50//! The Housekeeping path runs only when
51//! [`on_long_press`](GestureDetectorView::on_long_press) is wired: an
52//! `on_hold_progress`-only detector keeps its final observation deferred to the
53//! next pointer event, and when both are wired and `on_long_press` fires via
54//! Housekeeping the paired final `1.0` (below) rides along in the same pass. The
55//! paint timer itself runs for either handler and never for a **tap-only**
56//! detector, which therefore never marks `elapsed`: a press held past the threshold
57//! still resolves as an ordinary tap on an in-bounds release (the fire-on-up-inside
58//! contract is independent of hold duration when there is no long-press to promote
59//! it to), and the pending-timer [`PaintCtx::request_frame`] calls are skipped — a
60//! small battery win, since a tap-only hold needs no clock. A held-past-threshold
61//! resolution falls through to `on_tap` whenever `on_long_press` is absent (e.g.
62//! unwired mid-gesture), so a held press never silently swallows its release.
63//!
64//! Frame liveness while the finger is held still is guaranteed on mobile by the
65//! pointer capture keeping the `FrameGate` running; desktop is dirty-driven, so
66//! paint calls [`PaintCtx::request_frame`] both while the timer is pending, and
67//! once more on the frame that crosses the threshold — that second request is
68//! what actually reaches the Housekeeping-flushing rebuild above on a desktop
69//! shell with no further input.
70//!
71//! # Hold-progress observation
72//!
73//! [`on_hold_progress`](GestureDetectorView::on_hold_progress) reuses the same
74//! paint-clock timer as `on_long_press` to let a widget render charge-up feedback
75//! (e.g. a charge ring) for a held press. Paint computes progress —
76//! `(frame_time - press_start) / threshold`, clamped `0.0..=1.0` — every frame
77//! while a press is pending, but (the same callback-from-paint caveat as above)
78//! can only *record* it; the observation is delivered on the next pointer event
79//! that already carries a mutable `EventCtx` (an in-slop `Move`, matching
80//! `on_long_press`'s fire-on-move-arrival) — **or, when `on_long_press` is also
81//! wired, on the Housekeeping-triggered fire above, whichever reaches the widget
82//! first.** A drag past [`TOUCH_SLOP`] or an early release *before* the threshold
83//! — i.e. the `Move`/`Up` arm handling that transition, never
84//! `PointerPhase::Cancel` (see the Cancel staleness gap below) — delivers a final
85//! `0.0`; reaching the threshold delivers a final `1.0`. An
86//! `on_hold_progress`-only detector (no `on_long_press` wired) has its final
87//! `1.0` deferred to the next pointer event too — the Housekeeping path never
88//! runs for it. [`hold_threshold_ms`](GestureDetectorView::hold_threshold_ms)
89//! overrides [`LONG_PRESS_MS`] for both `on_long_press` and this timer.
90//!
91//! ## Cancel staleness gap
92//!
93//! A platform `Cancel` (gesture steal, e.g. a parent `ScrollView` claiming the
94//! drag; or structural teardown, e.g. the child subtree changing shape mid-hold)
95//! delivers **no** final observation — the Cancel-never-mutates-state convention
96//! (`docs/CODE_STANDARDS.md`) means `on_hold_progress` is never called from that
97//! arm. The consumer's last-observed `progress` therefore stays stale (whatever
98//! it was mid-hold) until a full new press cycle (`Down`→...→`Up`/threshold)
99//! delivers a fresh `0.0`/`1.0` through the normal path — there is no
100//! Cancel-delivered reset. **Consumer-side reset idiom:** since a fresh press
101//! cycle's *first* observation is always a low value counting up from near `0.0`,
102//! a consumer that stored a stale non-zero `progress` can self-correct at the top
103//! of its `on_hold_progress` callback by treating an incoming value lower than
104//! the last-stored one as the new cycle's baseline rather than carrying the stale
105//! high value forward — `demo_charge_ring` in `examples/glyph-catalog`'s
106//! `pages/interactions.rs` is a worked instance. Do not add a Cancel-arm callback
107//! to close this gap; it would violate that convention.
108
109use std::rc::Rc;
110
111use frust_core::{
112 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FrameTime,
113 InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, TOUCH_SLOP, View,
114 Widget, any,
115};
116use kurbo::{Point, Size};
117
118use crate::authoring::presses;
119
120/// The press duration after which a held press becomes a long-press.
121///
122/// **Community-approximate**: neither platform publishes this as a stable
123/// numeric constant, but the community-converged values agree closely —
124/// Android's `ViewConfiguration` long-press timeout defaults to ~400–500ms and
125/// iOS's `UILongPressGestureRecognizer.minimumPressDuration` defaults to 0.5s.
126/// 500ms sits at that shared upper bound.
127///
128/// `pub(crate)` because [`crate::textinput`] recognises its own stationary
129/// long-press — a text field cannot delegate the gesture to a wrapper, since
130/// the recogniser has to reach the editor's own hit test — and a field that
131/// held for a different duration than every `GestureDetector` around it would
132/// read as an inconsistency rather than a tuning choice. The matching slop is
133/// [`frust_core::TOUCH_SLOP`], which both already share.
134pub(crate) const LONG_PRESS_MS: f64 = 500.0;
135
136/// The window within which a second press near the first is a double-tap.
137///
138/// Android publishes this one as a real constant:
139/// `ViewConfiguration.getDoubleTapTimeout()` is `DOUBLE_TAP_TIMEOUT = 300`ms
140/// (`android.view.ViewConfiguration`). Apple exposes no equivalent number —
141/// `UITapGestureRecognizer` recognises a multi-tap internally and publishes no
142/// timeout — so the one published value stands for both platforms, paired with
143/// the same [`frust_core::TOUCH_SLOP`] the long-press uses.
144///
145/// Consumed by [`crate::textinput`]'s select-the-word-on-double-tap; the
146/// detector in this module still recognises tap + long-press only (double-tap
147/// is deferred there — see the [module docs](self)).
148pub(crate) const DOUBLE_TAP_MS: f64 = 300.0;
149
150/// A view-held, typed gesture callback (erased on build). Shared by `on_tap`
151/// and `on_long_press` — both are `Fn(&mut State)`.
152type GestureCallback<State> = Rc<dyn Fn(&mut State)>;
153
154/// A declarative gesture wrapper. See the [module docs](self).
155pub struct GestureDetectorView<State: 'static> {
156 child: AnyView<State>,
157 on_tap: Option<GestureCallback<State>>,
158 on_long_press: Option<GestureCallback<State>>,
159 on_hold_progress: Option<crate::authoring::TypedArgCallback<State, f64>>,
160 hold_threshold_ms: Option<u64>,
161}
162
163/// Wrap `child` in a gesture detector (no recognisers until one is attached,
164/// e.g. with [`GestureDetectorView::on_tap`] or
165/// [`GestureDetectorView::on_long_press`]).
166#[allow(non_snake_case)]
167pub fn GestureDetector<State: 'static, V: View<State>>(child: V) -> GestureDetectorView<State> {
168 GestureDetectorView {
169 child: any(child),
170 on_tap: None,
171 on_long_press: None,
172 on_hold_progress: None,
173 hold_threshold_ms: None,
174 }
175}
176
177impl<State: 'static> GestureDetectorView<State> {
178 /// Fire `on_tap` when the child is tapped (a press+release within
179 /// [`TOUCH_SLOP`] and shorter than [`LONG_PRESS_MS`]).
180 pub fn on_tap<F: Fn(&mut State) + 'static>(mut self, on_tap: F) -> Self {
181 self.on_tap = Some(Rc::new(on_tap));
182 self
183 }
184
185 /// Fire `on_long_press` when the child is pressed and held past
186 /// [`LONG_PRESS_MS`] without moving beyond [`TOUCH_SLOP`]. Composes with
187 /// [`on_tap`](Self::on_tap): a given press fires exactly one of the two,
188 /// never both (see the [module docs](self) for the firing semantics).
189 pub fn on_long_press<F: Fn(&mut State) + 'static>(mut self, on_long_press: F) -> Self {
190 self.on_long_press = Some(Rc::new(on_long_press));
191 self
192 }
193
194 /// Observe press-hold progress (`0.0..=1.0` against
195 /// [`hold_threshold_ms`](Self::hold_threshold_ms), default [`LONG_PRESS_MS`])
196 /// while the child is held — the primitive a charge-up ring or similar hold
197 /// feedback widget renders from. See the [module docs](self#hold-progress-observation)
198 /// for the delivery/firing semantics: paint-clock timed, delivered on the
199 /// next pointer event, with a final `0.0` on an early lift/drag-past-slop
200 /// or a final `1.0` on reaching the threshold.
201 ///
202 /// **Cancel staleness gap**: a platform `PointerPhase::Cancel` (gesture
203 /// steal or structural teardown) delivers **no** final observation — the
204 /// last value this callback saw stays stale until the next full press
205 /// cycle. See the [module docs](self#cancel-staleness-gap) for the
206 /// consumer-side reset idiom that self-corrects on the next touch-down
207 /// rather than waiting for a full press-release.
208 pub fn on_hold_progress<F: Fn(&mut State, f64) + 'static>(
209 mut self,
210 on_hold_progress: F,
211 ) -> Self {
212 self.on_hold_progress = Some(Rc::new(on_hold_progress));
213 self
214 }
215
216 /// Override the hold threshold (milliseconds), used by both
217 /// [`on_long_press`](Self::on_long_press) and
218 /// [`on_hold_progress`](Self::on_hold_progress); defaults to [`LONG_PRESS_MS`].
219 ///
220 /// Resolved with a floor of 1ms (`ms.max(1)`) at both `build`/`rebuild` —
221 /// `hold_threshold_ms(0)` never divides progress by zero (`f64::clamp`
222 /// passes a `NaN` numerator/denominator-zero result straight through
223 /// rather than clamping it away), so it instead yields a defined, finite
224 /// progress that reaches `1.0` on the very next paint.
225 pub fn hold_threshold_ms(mut self, ms: u64) -> Self {
226 self.hold_threshold_ms = Some(ms);
227 self
228 }
229}
230
231/// The gesture recogniser's state machine. `Copy` so an event arm can inspect it
232/// by value without holding a borrow of the widget while it reassigns.
233#[derive(Clone, Copy)]
234enum Recognizer {
235 /// No press in flight.
236 Idle,
237 /// A press is down and still within the slop (tap + long-press both viable).
238 /// `press_start` is recorded on the first paint after `Down`; `elapsed`
239 /// flips once a paint observes the hold exceeding the threshold — the same
240 /// paint also latches a Housekeeping flush when `on_long_press` is wired
241 /// (see the [module docs](self#long-press-firing-semantics)). `progress`
242 /// is paint's latest `0.0..=1.0` hold-progress observation, delivered to
243 /// `on_hold_progress` on the next pointer event or the Housekeeping-triggered
244 /// fire, whichever arrives first (see the [module
245 /// docs](self#hold-progress-observation)).
246 Pressed {
247 down_pos: Point,
248 press_start: Option<FrameTime>,
249 elapsed: bool,
250 progress: f64,
251 },
252 /// The press moved past the slop — became a drag; neither recogniser fires.
253 Dragged,
254 /// A long-press already fired for this press — via the threshold-time
255 /// Housekeeping broadcast (the common case; see the [module
256 /// docs](self#long-press-firing-semantics)) or a post-threshold `Move`/`Up`
257 /// arrival that won the race instead; we hold capture until `Up`/`Cancel`
258 /// but fire nothing further.
259 Fired,
260}
261
262/// The retained widget for a [`GestureDetectorView`].
263pub struct GestureDetectorWidget {
264 child: ChildPod,
265 state: Recognizer,
266 on_tap: Option<crate::authoring::ErasedCallback>,
267 on_long_press: Option<crate::authoring::ErasedCallback>,
268 on_hold_progress: Option<crate::authoring::ErasedArgCallback<f64>>,
269 /// Resolved hold threshold in ms — [`GestureDetectorView::hold_threshold_ms`]
270 /// if set, else [`LONG_PRESS_MS`]. Shared by the long-press timer and the
271 /// hold-progress computation.
272 threshold_ms: f64,
273}
274
275impl<State: 'static> View<State> for GestureDetectorView<State> {
276 type Element = GestureDetectorWidget;
277
278 fn build(&self, ctx: &mut BuildCtx<'_>) -> GestureDetectorWidget {
279 GestureDetectorWidget {
280 child: crate::authoring::build_child(&self.child, ctx),
281 state: Recognizer::Idle,
282 on_tap: self.on_tap.as_ref().map(crate::authoring::erase_callback),
283 on_long_press: self
284 .on_long_press
285 .as_ref()
286 .map(crate::authoring::erase_callback),
287 on_hold_progress: self
288 .on_hold_progress
289 .as_ref()
290 .map(crate::authoring::erase_callback_arg),
291 // `.max(1)` guards against a 0ms override: `f64::clamp` passes
292 // NaN through unchanged, so an unguarded `0.0 / 0.0` divisor in
293 // paint's progress computation would poison every subsequent
294 // observation. See `hold_threshold_ms`'s doc comment.
295 threshold_ms: self
296 .hold_threshold_ms
297 .map(|ms| ms.max(1) as f64)
298 .unwrap_or(LONG_PRESS_MS),
299 }
300 }
301
302 fn rebuild(
303 &self,
304 prev: &Self,
305 element: &mut GestureDetectorWidget,
306 ctx: &mut BuildCtx<'_>,
307 ) -> ChangeFlags {
308 element.on_tap = self.on_tap.as_ref().map(crate::authoring::erase_callback);
309 element.on_long_press = self
310 .on_long_press
311 .as_ref()
312 .map(crate::authoring::erase_callback);
313 element.on_hold_progress = self
314 .on_hold_progress
315 .as_ref()
316 .map(crate::authoring::erase_callback_arg);
317 // Same NaN guard as `build` above — see `hold_threshold_ms`'s doc comment.
318 element.threshold_ms = self
319 .hold_threshold_ms
320 .map(|ms| ms.max(1) as f64)
321 .unwrap_or(LONG_PRESS_MS);
322 crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
323 }
324
325 fn teardown(&self, element: &mut GestureDetectorWidget, ctx: &mut BuildCtx<'_>) {
326 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
327 }
328}
329
330impl Widget for GestureDetectorWidget {
331 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
332 let size = self.child.layout_child(ctx, bc);
333 self.child.set_origin(Point::ZERO);
334 bc.constrain(size)
335 }
336
337 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
338 // Measure the long-press/hold-progress across paints (the only pass
339 // with a clock). Record the press epoch on the first paint, mark
340 // `elapsed` once the hold exceeds the threshold, refresh the
341 // `0.0..=1.0` progress observation every paint, and — while the timer
342 // is still pending — request another frame so the dirty-driven desktop
343 // shell keeps painting with no input (mobile keeps the FrameGate alive
344 // via pointer capture).
345 //
346 // Only run the timer when an `on_long_press`/`on_hold_progress` handler
347 // is wired: a tap-only detector has no long-press to mark and no
348 // progress to observe, so it neither flips `elapsed` (which would
349 // otherwise swallow the release, since a held press resolves as a
350 // would-be long-press that fires nothing) nor request_frame's
351 // pointlessly during the hold — a small battery win.
352 if (self.on_long_press.is_some() || self.on_hold_progress.is_some())
353 && let Recognizer::Pressed {
354 press_start,
355 elapsed,
356 progress,
357 ..
358 } = &mut self.state
359 {
360 let start = *press_start.get_or_insert(ctx.frame_time());
361 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
362 *progress = (elapsed_ms / self.threshold_ms).clamp(0.0, 1.0);
363 if !*elapsed {
364 if elapsed_ms >= self.threshold_ms {
365 *elapsed = true;
366 // Threshold-time firing (see the module docs' "Long-press
367 // firing semantics"): paint can't call `on_long_press`
368 // itself (no `EventCtx` here), so latch the same
369 // deferred-callback flush `nav::navigator` uses for a
370 // queued pop-result, and request one more frame so a
371 // dirty-driven desktop shell actually reaches the
372 // `RenderRoot::rebuild` that drains it — every shell runs
373 // rebuild (with a real `EventCtx`-bearing `Housekeeping`
374 // dispatch) before its own paint, so the very next frame
375 // fires this at the threshold, in wall time, with no
376 // pointer event required. Gated on `on_long_press` being
377 // wired: an `on_hold_progress`-only detector has its
378 // delivery deferred to the next pointer event instead.
379 if self.on_long_press.is_some() {
380 frust_core::mark_pending_result_flush();
381 ctx.request_frame();
382 }
383 } else {
384 ctx.request_frame();
385 }
386 }
387 }
388 self.child.paint_child(ctx, scene);
389 }
390
391 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
392 if let InputEvent::Pointer(p) = event {
393 match p.phase {
394 PointerPhase::Down => {
395 // Only a primary press arms a recognizer. A secondary press
396 // is a context gesture — it starts neither a tap nor a
397 // long-press and opens no capture — but it still reaches the
398 // child, whose own context handling (if any) owns it.
399 if !presses(p) {
400 return self.child.event_child(ctx, event);
401 }
402 self.state = Recognizer::Pressed {
403 down_pos: p.position,
404 press_start: None,
405 elapsed: false,
406 progress: 0.0,
407 };
408 ctx.capture_pointer();
409 // Prompt a paint so the long-press/progress timer starts promptly.
410 ctx.request_redraw();
411 self.child.event_child(ctx, event);
412 return EventResult::Handled;
413 }
414 PointerPhase::Move => {
415 if let Recognizer::Pressed {
416 down_pos,
417 elapsed,
418 progress,
419 ..
420 } = self.state
421 {
422 if (p.position - down_pos).hypot() > TOUCH_SLOP {
423 // Became a drag — disarms both. A mid-hold progress
424 // observation resets to exactly 0.0 (never a stale
425 // >0 value left behind); this is the Move arm, not
426 // Cancel, so it's allowed to touch state.
427 self.state = Recognizer::Dragged;
428 if let Some(cb) = self.on_hold_progress.as_mut() {
429 cb(ctx, 0.0);
430 ctx.request_redraw();
431 }
432 } else {
433 // Deliver the latest paint-observed progress: paint has
434 // no EventCtx, so this in-slop Move is the deferred-fire
435 // opportunity (mirrors on_long_press's fire-on-move-arrival
436 // below — this also delivers the final 1.0 once `elapsed`).
437 if let Some(cb) = self.on_hold_progress.as_mut() {
438 cb(ctx, progress);
439 ctx.request_redraw();
440 }
441 if elapsed {
442 // Threshold already passed and a pointer event arrived:
443 // fire the long-press now (fire-on-move-arrival). If the
444 // handler was unwired mid-gesture (rebuild between the
445 // marking paint and this Move), fall through to on_tap
446 // so the press still resolves rather than silently dying.
447 self.state = Recognizer::Fired;
448 self.child.event_child(ctx, event);
449 if let Some(cb) = self.on_long_press.as_mut() {
450 cb(ctx);
451 ctx.request_redraw();
452 } else if let Some(cb) = self.on_tap.as_mut() {
453 cb(ctx);
454 ctx.request_redraw();
455 }
456 return EventResult::Handled;
457 }
458 }
459 }
460 self.child.event_child(ctx, event);
461 return EventResult::Handled;
462 }
463 PointerPhase::Up => {
464 // Resolve at most one recogniser: a held-past-threshold press
465 // releases as a long-press, otherwise a within-slop press is
466 // a tap. Never both.
467 let fire_long = matches!(self.state, Recognizer::Pressed { elapsed: true, .. });
468 let fire_tap = matches!(self.state, Recognizer::Pressed { elapsed: false, .. });
469 // A final hold-progress observation, only if the press was
470 // still `Pressed` at Up (a `Fired`/`Dragged` state already
471 // delivered its final call from the Move arm above): 1.0 if
472 // the threshold was reached, else a clean 0.0 (early lift).
473 let final_progress = match self.state {
474 Recognizer::Pressed { elapsed: true, .. } => Some(1.0),
475 Recognizer::Pressed { elapsed: false, .. } => Some(0.0),
476 _ => None,
477 };
478 self.state = Recognizer::Idle;
479 self.child.event_child(ctx, event);
480 self.child.set_active(false);
481 if let Some(progress) = final_progress
482 && let Some(cb) = self.on_hold_progress.as_mut()
483 {
484 cb(ctx, progress);
485 ctx.request_redraw();
486 }
487 // A held-past-threshold press prefers on_long_press, but falls
488 // through to on_tap when no long-press handler is wired — a
489 // tap-only detector must still fire the tap on an in-bounds
490 // release (the paint timer never marks `elapsed` for it, but
491 // stay robust to a handler unwired mid-gesture).
492 if fire_long && let Some(cb) = self.on_long_press.as_mut() {
493 cb(ctx);
494 ctx.request_redraw();
495 } else if (fire_tap || fire_long)
496 && let Some(cb) = self.on_tap.as_mut()
497 {
498 cb(ctx);
499 ctx.request_redraw();
500 }
501 return EventResult::Handled;
502 }
503 PointerPhase::Cancel => {
504 // Cancel only clears internal flags — never touches app state
505 // (no callback), per the interaction-semantics contract.
506 self.state = Recognizer::Idle;
507 self.child.event_child(ctx, event);
508 self.child.set_active(false);
509 return EventResult::Handled;
510 }
511 }
512 }
513 if event.is_broadcast() {
514 // `InputEvent::Housekeeping` today: the delivery vehicle for a
515 // threshold-time long-press latched during an earlier paint (see
516 // the module docs' "Long-press firing semantics") — the soonest
517 // pass carrying a real `EventCtx` when no pointer event arrived
518 // first. Gated on `on_long_press` being wired, mirroring paint's
519 // own gate: an `on_hold_progress`-only press never reaches this
520 // arm, so it keeps its original fire-on-move/up-arrival delivery
521 // untouched.
522 if self.on_long_press.is_some()
523 && let Recognizer::Pressed {
524 elapsed: true,
525 progress,
526 ..
527 } = self.state
528 {
529 // Whichever of this broadcast or a live pointer event reaches
530 // the widget first wins the fire; the other finds `state` no
531 // longer `Pressed` and is a no-op (fire-exactly-once).
532 self.state = Recognizer::Fired;
533 if let Some(cb) = self.on_long_press.as_mut() {
534 cb(ctx);
535 ctx.request_redraw();
536 }
537 // `on_hold_progress`'s paired final observation (already
538 // pinned at 1.0 by `elapsed`) rides along in the same pass,
539 // exactly as the Move-arrival path always delivered them
540 // together.
541 if let Some(cb) = self.on_hold_progress.as_mut() {
542 cb(ctx, progress);
543 ctx.request_redraw();
544 }
545 }
546 // A broadcast is never consumed and always forwarded to the
547 // child unconditionally, reporting Ignored regardless of what it
548 // returns (`docs/CODE_STANDARDS.md`'s interaction-semantics
549 // convention).
550 self.child.event_child(ctx, event);
551 return EventResult::Ignored;
552 }
553 // Non-broadcast, non-pointer events (scroll/key/IME) pass straight
554 // through to the child, reporting whatever it returns.
555 self.child.event_child(ctx, event)
556 }
557
558 fn semantics(&self, ctx: &mut SemanticsCtx) {
559 // Transparent gesture-recognizer wrapper: forward to the single child.
560 self.child.semantics_child(ctx);
561 }
562
563 crate::authoring::visit_children!(child);
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use frust_core::RenderRoot;
570 use std::any::Any;
571
572 #[derive(Default)]
573 struct TapState {
574 taps: u32,
575 long_presses: u32,
576 }
577
578 /// A trivial full-bleed child that ignores events. Generic over `State` so
579 /// it's reusable across both `TapState` and `HoldState` test fixtures.
580 struct Blank;
581 struct BlankWidget;
582 impl<State: 'static> View<State> for Blank {
583 type Element = BlankWidget;
584 fn build(&self, _ctx: &mut BuildCtx<'_>) -> BlankWidget {
585 BlankWidget
586 }
587 fn rebuild(
588 &self,
589 _prev: &Self,
590 _element: &mut BlankWidget,
591 _ctx: &mut BuildCtx<'_>,
592 ) -> ChangeFlags {
593 ChangeFlags::NONE
594 }
595 }
596 impl Widget for BlankWidget {
597 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
598 bc.max()
599 }
600 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
601 }
602
603 fn widget(with_tap: bool) -> GestureDetectorWidget {
604 let mut view = GestureDetector::<TapState, _>(Blank);
605 if with_tap {
606 view = view.on_tap(|s: &mut TapState| s.taps += 1);
607 }
608 let mut counter = 0u64;
609 View::<TapState>::build(&view, &mut BuildCtx::new(&mut counter))
610 }
611
612 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
613 InputEvent::Pointer(frust_core::PointerEvent {
614 phase,
615 position: Point::new(x, y),
616 button: frust_core::PointerButton::Primary,
617 })
618 }
619
620 fn dispatch(w: &mut GestureDetectorWidget, state: &mut TapState, event: &InputEvent) {
621 let state_any: &mut dyn Any = state;
622 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
623 w.event(&mut ctx, event);
624 }
625
626 #[test]
627 fn press_release_within_slop_is_a_tap() {
628 let mut w = widget(true);
629 let mut state = TapState::default();
630 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
631 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 15.0, 12.0));
632 assert_eq!(state.taps, 1);
633 }
634
635 #[test]
636 fn drag_past_slop_disarms_the_tap() {
637 let mut w = widget(true);
638 let mut state = TapState::default();
639 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
640 // 30 px down: well past TOUCH_SLOP (18).
641 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 10.0, 40.0));
642 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 40.0));
643 assert_eq!(state.taps, 0);
644 }
645
646 #[test]
647 fn cancel_disarms_the_tap() {
648 let mut w = widget(true);
649 let mut state = TapState::default();
650 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
651 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
652 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
653 assert_eq!(state.taps, 0);
654 }
655
656 #[test]
657 fn no_tap_callback_is_a_benign_noop() {
658 let mut w = widget(false);
659 let mut state = TapState::default();
660 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
661 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
662 assert_eq!(state.taps, 0);
663 }
664
665 // --- Long-press ------------------------------------------------------------
666 //
667 // The long-press timer is measured across paints, so these tests drive the
668 // real paint path through `RenderRoot` (the only place a `FrameTime` is
669 // threaded in) with an advancing injected frame clock, exactly like the
670 // scroll/textinput clock tests.
671
672 /// A no-op paint sink for the clock-driven tests.
673 struct NullScene;
674 impl PaintScene for NullScene {
675 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
676 fn draw_text(&mut self, _o: Point, _t: &str) {}
677 }
678
679 /// A frame time `ms` milliseconds from an arbitrary origin.
680 fn ft(ms: f64) -> FrameTime {
681 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
682 }
683
684 /// A `RenderRoot` over a `GestureDetector` wired with both callbacks.
685 fn root() -> RenderRoot<TapState, GestureDetectorView<TapState>> {
686 fn logic(_: &mut TapState) -> GestureDetectorView<TapState> {
687 GestureDetector::<TapState, _>(Blank)
688 .on_tap(|s: &mut TapState| s.taps += 1)
689 .on_long_press(|s: &mut TapState| s.long_presses += 1)
690 }
691 let mut root = RenderRoot::new();
692 let mut state = TapState::default();
693 root.rebuild(&mut logic, &mut state);
694 root.layout(Size::new(100.0, 100.0));
695 root
696 }
697
698 #[test]
699 fn hold_past_threshold_then_up_fires_long_press_only() {
700 let mut root = root();
701 let mut state = TapState::default();
702 let mut sink = NullScene;
703 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
704 root.paint(&mut sink, ft(0.0)); // records press_start = 0
705 root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms → elapsed marked
706 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
707 assert_eq!(state.long_presses, 1, "held press releases as a long-press");
708 assert_eq!(state.taps, 0, "a long-press must not also fire on_tap");
709 }
710
711 /// The same view `root()` builds, standalone — for a test that needs to
712 /// call `RenderRoot::rebuild` a second time against the same retained
713 /// widget (simulating a real shell's next per-frame rebuild).
714 fn long_press_logic(_: &mut TapState) -> GestureDetectorView<TapState> {
715 GestureDetector::<TapState, _>(Blank)
716 .on_tap(|s: &mut TapState| s.taps += 1)
717 .on_long_press(|s: &mut TapState| s.long_presses += 1)
718 }
719
720 #[test]
721 fn stationary_hold_fires_on_long_press_at_the_threshold_with_no_pointer_event() {
722 // The regression this task fixes: before it, a perfectly stationary
723 // finger got nothing until `Up` — paint only *marked* `elapsed`, and
724 // the callback fired on the next pointer event (`Move`/`Up`). Now
725 // paint latches a Housekeeping flush the instant it crosses the
726 // threshold, and the very next `RenderRoot::rebuild` (which every
727 // shell runs before its own paint) drains it and fires — with zero
728 // pointer events anywhere in this test.
729 let mut root = root();
730 let mut state = TapState::default();
731 let mut sink = NullScene;
732 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
733 root.paint(&mut sink, ft(0.0)); // records press_start = 0
734 let crossing = root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms → elapsed marked, flush latched
735 assert!(
736 crossing.needs_frame,
737 "the threshold-crossing paint requests a follow-up frame so a \
738 dirty-driven desktop shell actually reaches the rebuild that fires"
739 );
740 // Paint alone never fires — there is no `EventCtx` in that pass.
741 assert_eq!(state.long_presses, 0, "paint only latches; it never fires");
742
743 // The follow-up frame: every shell runs `rebuild` before `paint`,
744 // and `rebuild` is what drains the flush and dispatches the
745 // `Housekeeping` broadcast this widget fires from.
746 let flags = root.rebuild(&mut long_press_logic, &mut state);
747 assert_eq!(
748 state.long_presses, 1,
749 "a stationary hold fires on_long_press at the threshold, in \
750 wall time, with no pointer event"
751 );
752 assert_eq!(state.taps, 0);
753
754 // End-to-end wake: this consumer mutates only a counter the view never
755 // reads, so the re-diff inside `rebuild` sees nothing — the repaint the
756 // fire asked for exists only because `RenderRoot::rebuild` propagates
757 // the Housekeeping dispatch's `EventOutcome` (see the module docs'
758 // "Long-press firing semantics" and `frust-core`'s flush loop).
759 assert!(
760 flags.needs_paint(),
761 "the fire's `request_redraw` folds into the rebuild's flags, which \
762 is what the mobile frame gate reads"
763 );
764 let after = root.paint(&mut sink, ft(620.0));
765 assert!(
766 after.needs_frame,
767 "and surfaces as `needs_frame` for the desktop `Wait` loop — the \
768 widget itself stops requesting frames once it has fired"
769 );
770 }
771
772 #[test]
773 fn housekeeping_fire_wins_the_race_and_a_later_move_or_up_is_inert() {
774 // If the Housekeeping broadcast reaches the widget before any
775 // further pointer event does, it fires there — and a `Move`/`Up`
776 // arriving afterwards finds `state` already `Fired`, so neither may
777 // double-fire (fire-exactly-once, regardless of which delivery path
778 // won the race).
779 let mut root = root();
780 let mut state = TapState::default();
781 let mut sink = NullScene;
782 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
783 root.paint(&mut sink, ft(0.0));
784 root.paint(&mut sink, ft(600.0)); // elapsed marked, flush latched
785 root.rebuild(&mut long_press_logic, &mut state); // Housekeeping fires here
786 assert_eq!(state.long_presses, 1);
787
788 root.event(&mut state, &ev(PointerPhase::Move, 11.0, 11.0));
789 assert_eq!(
790 state.long_presses, 1,
791 "a post-fire Move must not double-fire"
792 );
793 assert_eq!(state.taps, 0);
794
795 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
796 assert_eq!(state.long_presses, 1, "a post-fire Up must not double-fire");
797 assert_eq!(
798 state.taps, 0,
799 "a post-fire Up must not fall through to on_tap"
800 );
801 }
802
803 #[test]
804 fn drag_before_threshold_then_rebuild_never_fires_via_housekeeping() {
805 // A slop-cancel before the threshold must stay disarmed even across
806 // a later rebuild — the flush is only latched at the
807 // threshold-crossing paint, which a pre-threshold drag never
808 // reaches.
809 let mut root = root();
810 let mut state = TapState::default();
811 let mut sink = NullScene;
812 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
813 root.paint(&mut sink, ft(0.0));
814 root.paint(&mut sink, ft(100.0)); // short of the 500ms threshold
815 root.event(&mut state, &ev(PointerPhase::Move, 10.0, 40.0)); // past slop -> Dragged
816 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 40.0));
817 root.rebuild(&mut long_press_logic, &mut state);
818 assert_eq!(
819 state.long_presses, 0,
820 "a pre-threshold drag never fires via Housekeeping"
821 );
822 assert_eq!(state.taps, 0, "a dragged press is not a tap either");
823 }
824
825 #[test]
826 fn quick_tap_before_threshold_fires_tap_only() {
827 let mut root = root();
828 let mut state = TapState::default();
829 let mut sink = NullScene;
830 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
831 root.paint(&mut sink, ft(0.0));
832 root.paint(&mut sink, ft(100.0)); // 100ms < 500ms → not elapsed
833 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 12.0));
834 assert_eq!(state.taps, 1, "a quick release is a tap");
835 assert_eq!(state.long_presses, 0, "under threshold must not long-press");
836 }
837
838 #[test]
839 fn quick_tap_then_rebuild_never_retroactively_long_presses() {
840 // A quick release before the threshold resolves as a tap; a later
841 // rebuild (which would dispatch Housekeeping if anything were
842 // pending) must not retroactively promote it to a long-press.
843 let mut root = root();
844 let mut state = TapState::default();
845 let mut sink = NullScene;
846 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
847 root.paint(&mut sink, ft(0.0));
848 root.paint(&mut sink, ft(100.0)); // short of the threshold
849 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 12.0));
850 root.rebuild(&mut long_press_logic, &mut state);
851 assert_eq!(state.taps, 1, "the tap already resolved on Up");
852 assert_eq!(
853 state.long_presses, 0,
854 "a subsequent rebuild must not retroactively long-press a released tap"
855 );
856 }
857
858 #[test]
859 fn move_after_threshold_fires_long_press_before_release() {
860 let mut root = root();
861 let mut state = TapState::default();
862 let mut sink = NullScene;
863 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
864 root.paint(&mut sink, ft(0.0));
865 root.paint(&mut sink, ft(600.0)); // elapsed
866 // A tiny jitter (within slop) after the threshold fires immediately.
867 root.event(&mut state, &ev(PointerPhase::Move, 12.0, 11.0));
868 assert_eq!(state.long_presses, 1, "fires on the post-threshold move");
869 // A following Up must not double-fire (long or tap).
870 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 11.0));
871 assert_eq!(
872 state.long_presses, 1,
873 "Up after a fired long-press is inert"
874 );
875 assert_eq!(state.taps, 0);
876 }
877
878 #[test]
879 fn hold_then_drag_then_up_fires_neither() {
880 let mut root = root();
881 let mut state = TapState::default();
882 let mut sink = NullScene;
883 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
884 root.paint(&mut sink, ft(0.0));
885 root.paint(&mut sink, ft(600.0)); // elapsed
886 // Drag past the slop wins over the elapsed timer.
887 root.event(&mut state, &ev(PointerPhase::Move, 10.0, 40.0));
888 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 40.0));
889 assert_eq!(state.long_presses, 0, "a drag disarms the long-press");
890 assert_eq!(state.taps, 0, "a drag disarms the tap");
891 }
892
893 #[test]
894 fn cancel_mid_hold_fires_neither_and_leaves_state_untouched() {
895 let mut root = root();
896 let mut state = TapState::default();
897 let mut sink = NullScene;
898 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
899 root.paint(&mut sink, ft(0.0));
900 root.paint(&mut sink, ft(600.0)); // elapsed
901 root.event(&mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
902 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 10.0));
903 assert_eq!(state.long_presses, 0, "Cancel fires no long-press");
904 assert_eq!(state.taps, 0, "Cancel fires no tap");
905 }
906
907 #[test]
908 fn paint_requests_frames_while_pressed_and_stops_after_up() {
909 let mut root = root();
910 let mut state = TapState::default();
911 let mut sink = NullScene;
912 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
913 // The timer is pending: paint asks the shell to keep painting.
914 assert!(
915 root.paint(&mut sink, ft(0.0)).needs_frame,
916 "a pending long-press timer requests continuation frames"
917 );
918 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 10.0));
919 // Released: no timer, no continuation frame.
920 assert!(
921 !root.paint(&mut sink, ft(16.0)).needs_frame,
922 "the timer stops requesting frames after Up"
923 );
924 }
925
926 #[test]
927 fn only_on_tap_still_behaves_as_before() {
928 // Regression: a detector with no long-press wired taps exactly as v1.
929 let mut w = widget(true);
930 let mut state = TapState::default();
931 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
932 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
933 assert_eq!(state.taps, 1);
934 assert_eq!(state.long_presses, 0);
935 }
936
937 // --- Tap-only held press --------------------------------------------------
938 //
939 // The event-only `only_on_tap_still_behaves_as_before` above never paints, so
940 // its clock never crosses the threshold. These drive the real paint path with
941 // an advancing clock (like the both-handlers tests above) but wire ONLY
942 // `on_tap`, pinning that a tap-only detector never marks `elapsed` — marking
943 // it would resolve the press as a would-be long-press with no handler and
944 // fire NOTHING, swallowing the release.
945
946 /// A `RenderRoot` over a `GestureDetector` wired with ONLY `on_tap`.
947 fn tap_only_root() -> RenderRoot<TapState, GestureDetectorView<TapState>> {
948 fn logic(_: &mut TapState) -> GestureDetectorView<TapState> {
949 GestureDetector::<TapState, _>(Blank).on_tap(|s: &mut TapState| s.taps += 1)
950 }
951 let mut root = RenderRoot::new();
952 let mut state = TapState::default();
953 root.rebuild(&mut logic, &mut state);
954 root.layout(Size::new(100.0, 100.0));
955 root
956 }
957
958 #[test]
959 fn tap_only_hold_past_threshold_then_up_still_fires_tap() {
960 // (a) hold past the threshold, release in-bounds → on_tap fires.
961 let mut root = tap_only_root();
962 let mut state = TapState::default();
963 let mut sink = NullScene;
964 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
965 root.paint(&mut sink, ft(0.0)); // records press_start = 0
966 root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms, but no long-press wired
967 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
968 assert_eq!(
969 state.taps, 1,
970 "a held press with no on_long_press still taps on release"
971 );
972 assert_eq!(state.long_presses, 0);
973 }
974
975 #[test]
976 fn tap_only_hold_then_within_slop_move_taps_on_release_not_on_move() {
977 // (b) hold past the threshold, a within-slop Move arrives after elapsed →
978 // nothing at the Move; on_tap still fires on release.
979 let mut root = tap_only_root();
980 let mut state = TapState::default();
981 let mut sink = NullScene;
982 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
983 root.paint(&mut sink, ft(0.0));
984 root.paint(&mut sink, ft(600.0));
985 root.event(&mut state, &ev(PointerPhase::Move, 12.0, 11.0));
986 assert_eq!(
987 state.taps, 0,
988 "a tap-only detector fires nothing on the move"
989 );
990 assert_eq!(state.long_presses, 0);
991 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 11.0));
992 assert_eq!(state.taps, 1, "the tap fires on the in-bounds release");
993 assert_eq!(state.long_presses, 0);
994 }
995
996 #[test]
997 fn tap_only_press_requests_no_continuation_frames() {
998 // The battery win: a tap-only detector runs no long-press timer, so a
999 // pending press must not keep the dirty-driven shell painting.
1000 let mut root = tap_only_root();
1001 let mut state = TapState::default();
1002 let mut sink = NullScene;
1003 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1004 assert!(
1005 !root.paint(&mut sink, ft(0.0)).needs_frame,
1006 "a tap-only press has no timer, so it requests no continuation frames"
1007 );
1008 }
1009
1010 #[test]
1011 fn both_handlers_long_press_behavior_unchanged() {
1012 // (c) with both handlers wired, a held-past-threshold release is still a
1013 // long-press and never also a tap — the fix must not regress this.
1014 let mut root = root();
1015 let mut state = TapState::default();
1016 let mut sink = NullScene;
1017 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1018 root.paint(&mut sink, ft(0.0));
1019 root.paint(&mut sink, ft(600.0));
1020 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
1021 assert_eq!(state.long_presses, 1, "both wired: hold still long-presses");
1022 assert_eq!(state.taps, 0, "both wired: a long-press never also taps");
1023 }
1024
1025 // --- Hold-progress observation ---------------------------------------------
1026 //
1027 // `on_hold_progress` rides the same paint-clock timer as `on_long_press`:
1028 // paint records the latest 0.0..=1.0 observation, and it's delivered on the
1029 // next pointer event carrying a mutable `EventCtx` — an in-slop `Move` (the
1030 // same fire-on-move-arrival opportunity `on_long_press` uses) or `Up`.
1031
1032 #[derive(Default)]
1033 struct HoldState {
1034 taps: u32,
1035 long_presses: u32,
1036 progress: Vec<f64>,
1037 }
1038
1039 fn hold_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1040 InputEvent::Pointer(frust_core::PointerEvent {
1041 phase,
1042 position: Point::new(x, y),
1043 button: frust_core::PointerButton::Primary,
1044 })
1045 }
1046
1047 /// A `RenderRoot` over a `GestureDetector` wired with all three callbacks,
1048 /// at the default [`LONG_PRESS_MS`] threshold.
1049 fn progress_root() -> RenderRoot<HoldState, GestureDetectorView<HoldState>> {
1050 fn logic(_: &mut HoldState) -> GestureDetectorView<HoldState> {
1051 GestureDetector::<HoldState, _>(Blank)
1052 .on_tap(|s: &mut HoldState| s.taps += 1)
1053 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1054 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1055 }
1056 let mut root = RenderRoot::new();
1057 let mut state = HoldState::default();
1058 root.rebuild(&mut logic, &mut state);
1059 root.layout(Size::new(100.0, 100.0));
1060 root
1061 }
1062
1063 /// Like [`progress_root`], but with an overridden `hold_threshold_ms`.
1064 fn progress_root_with_threshold(
1065 ms: u64,
1066 ) -> RenderRoot<HoldState, GestureDetectorView<HoldState>> {
1067 fn logic(state: &mut HoldState, ms: u64) -> GestureDetectorView<HoldState> {
1068 let _ = state;
1069 GestureDetector::<HoldState, _>(Blank)
1070 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1071 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1072 .hold_threshold_ms(ms)
1073 }
1074 let mut root = RenderRoot::new();
1075 let mut state = HoldState::default();
1076 root.rebuild(&mut |s: &mut HoldState| logic(s, ms), &mut state);
1077 root.layout(Size::new(100.0, 100.0));
1078 root
1079 }
1080
1081 #[test]
1082 fn hold_progress_increases_monotonically_and_long_press_still_fires() {
1083 // Criterion 1: progress observations increase monotonically 0->1;
1084 // on_long_press still fires per its existing contract.
1085 let mut root = progress_root();
1086 let mut state = HoldState::default();
1087 let mut sink = NullScene;
1088 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1089 root.paint(&mut sink, ft(0.0));
1090 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // in-slop jitter
1091 root.paint(&mut sink, ft(150.0));
1092 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1093 root.paint(&mut sink, ft(300.0));
1094 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 11.0));
1095 root.paint(&mut sink, ft(500.0)); // elapsed marked (500ms threshold)
1096 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 11.0)); // fires long-press
1097
1098 assert_eq!(
1099 state.progress,
1100 vec![0.0, 0.3, 0.6, 1.0],
1101 "progress observations increase monotonically 0..1"
1102 );
1103 assert_eq!(state.long_presses, 1, "threshold reached: long-press fires");
1104 assert_eq!(state.taps, 0);
1105
1106 // A following Up must not double-fire anything.
1107 root.event(&mut state, &hold_ev(PointerPhase::Up, 11.0, 11.0));
1108 assert_eq!(state.long_presses, 1);
1109 assert_eq!(
1110 state.progress.len(),
1111 4,
1112 "Up after a fired long-press adds no observation"
1113 );
1114 }
1115
1116 /// The same view [`progress_root`] builds, standalone — for a test that
1117 /// needs to call `RenderRoot::rebuild` a second time against the same
1118 /// retained widget (simulating a real shell's next per-frame rebuild).
1119 fn progress_logic(_: &mut HoldState) -> GestureDetectorView<HoldState> {
1120 GestureDetector::<HoldState, _>(Blank)
1121 .on_tap(|s: &mut HoldState| s.taps += 1)
1122 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1123 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1124 }
1125
1126 #[test]
1127 fn housekeeping_fire_delivers_the_paired_final_progress_and_a_later_drag_is_inert() {
1128 // A perfectly stationary hold with both `on_long_press` and
1129 // `on_hold_progress` wired: the Housekeeping-triggered fire delivers
1130 // both in the same pass (matching the paired delivery the
1131 // Move-arrival path has always given), and — since `state` is
1132 // `Fired`, not `Pressed`, afterwards — a subsequent Move past the
1133 // slop must not be mistaken for the Pressed→Dragged transition
1134 // (which would otherwise re-deliver a spurious final `0.0`); it just
1135 // forwards to the child, e.g. so an underlying scrollable can keep
1136 // tracking the drag.
1137 let mut root = progress_root();
1138 let mut state = HoldState::default();
1139 let mut sink = NullScene;
1140 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1141 root.paint(&mut sink, ft(0.0));
1142 root.paint(&mut sink, ft(500.0)); // elapsed marked, flush latched
1143 root.rebuild(&mut progress_logic, &mut state); // Housekeeping fires here
1144 assert_eq!(state.long_presses, 1);
1145 assert_eq!(
1146 state.progress,
1147 vec![1.0],
1148 "the paired final progress observation rides the same fire"
1149 );
1150
1151 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 40.0)); // past TOUCH_SLOP
1152 assert_eq!(state.long_presses, 1, "no double-fire on a post-fire drag");
1153 assert_eq!(
1154 state.progress,
1155 vec![1.0],
1156 "a post-fire Move delivers no further progress observation"
1157 );
1158 }
1159
1160 #[test]
1161 fn early_lift_delivers_a_final_zero_observation_and_no_long_press() {
1162 // Criterion 2: early lift at ~50% -> a final 0.0 observation; no long-press.
1163 let mut root = progress_root();
1164 let mut state = HoldState::default();
1165 let mut sink = NullScene;
1166 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1167 root.paint(&mut sink, ft(0.0));
1168 root.paint(&mut sink, ft(250.0)); // ~50% of the 500ms threshold, not elapsed
1169 root.event(&mut state, &hold_ev(PointerPhase::Up, 11.0, 11.0));
1170
1171 assert_eq!(
1172 state.progress.last().copied(),
1173 Some(0.0),
1174 "an early lift's final observation resets to exactly 0.0"
1175 );
1176 assert_eq!(state.long_presses, 0, "early lift never long-presses");
1177 }
1178
1179 #[test]
1180 fn move_past_slop_mid_hold_delivers_a_final_zero_and_no_long_press() {
1181 // Criterion 3: move past slop mid-hold -> cancel + 0.0; no long-press fire.
1182 let mut root = progress_root();
1183 let mut state = HoldState::default();
1184 let mut sink = NullScene;
1185 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1186 root.paint(&mut sink, ft(0.0));
1187 root.paint(&mut sink, ft(300.0)); // 60% held, not elapsed
1188 // 30px move: well past TOUCH_SLOP (18) -> becomes a drag.
1189 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 40.0));
1190 root.event(&mut state, &hold_ev(PointerPhase::Up, 10.0, 40.0));
1191
1192 assert_eq!(
1193 state.progress.last().copied(),
1194 Some(0.0),
1195 "a drag past slop delivers a final 0.0 observation"
1196 );
1197 assert_eq!(state.long_presses, 0);
1198 assert_eq!(state.taps, 0);
1199 }
1200
1201 #[test]
1202 fn hold_threshold_ms_override_is_respected_by_both_callbacks() {
1203 // Criterion 4: hold_threshold_ms(700) respected by both callbacks.
1204 let mut root = progress_root_with_threshold(700);
1205 let mut state = HoldState::default();
1206 let mut sink = NullScene;
1207 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1208 root.paint(&mut sink, ft(0.0));
1209 root.paint(&mut sink, ft(600.0)); // past the default 500ms, short of 700ms
1210 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0));
1211 assert_eq!(
1212 state.long_presses, 0,
1213 "the 700ms override must not fire long-press at 600ms"
1214 );
1215 assert!(
1216 (state.progress.last().copied().unwrap() - 600.0 / 700.0).abs() < 1e-9,
1217 "progress reflects the overridden 700ms threshold, not the 500ms default"
1218 );
1219
1220 root.paint(&mut sink, ft(700.0)); // now past the overridden threshold
1221 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1222 assert_eq!(
1223 state.long_presses, 1,
1224 "the 700ms override still fires long-press once reached"
1225 );
1226 assert_eq!(state.progress.last().copied(), Some(1.0));
1227 }
1228
1229 #[test]
1230 fn no_hold_progress_handler_is_a_benign_noop() {
1231 // Criterion 5: on_tap/on_long_press-only users unaffected.
1232 let mut w = widget(true);
1233 let mut state = TapState::default();
1234 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1235 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 11.0, 11.0));
1236 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1237 assert_eq!(state.taps, 1);
1238 assert_eq!(state.long_presses, 0);
1239 }
1240
1241 // --- Cancel staleness gap + zero-threshold NaN guard ---------------------
1242
1243 #[test]
1244 fn cancel_leaves_progress_stale_until_fresh_down_cycle_delivers_clean_value() {
1245 // Regression for the Cancel staleness gap (see gesture.rs module
1246 // docs): a platform Cancel delivers NO final observation, so the
1247 // consumer's last-observed progress stays whatever it was mid-hold.
1248 // A fresh Down-cycle's first observation must start low again, never
1249 // resuming from the stale value Cancel left behind — the reset idiom
1250 // works at the widget contract level.
1251 let mut root = progress_root();
1252 let mut state = HoldState::default();
1253 let mut sink = NullScene;
1254
1255 // Cycle 1: hold to ~30% progress, then Cancel (gesture steal).
1256 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1257 root.paint(&mut sink, ft(0.0));
1258 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // delivers 0.0
1259 root.paint(&mut sink, ft(150.0)); // 30% of the 500ms default threshold
1260 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0)); // delivers 0.3
1261 let before_cancel_len = state.progress.len();
1262 let stale_value = *state.progress.last().unwrap();
1263 assert!(
1264 stale_value > 0.0,
1265 "sanity: mid-hold progress is non-zero before Cancel"
1266 );
1267
1268 root.event(&mut state, &hold_ev(PointerPhase::Cancel, 11.0, 10.0));
1269 assert_eq!(
1270 state.progress.len(),
1271 before_cancel_len,
1272 "Cancel delivers no observation — the Cancel-never-mutates-state convention"
1273 );
1274
1275 // Cycle 2: a fresh Down cycle. The first delivered observation must
1276 // be a low, fresh value — never the stale value Cancel left behind.
1277 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1278 root.paint(&mut sink, ft(150.0)); // press_start re-anchors here; elapsed_ms = 0
1279 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0));
1280 let fresh_value = *state.progress.last().unwrap();
1281 assert!(
1282 fresh_value < stale_value,
1283 "a fresh Down-cycle's first observation starts low, self-correcting the stale \
1284 ring (fresh={fresh_value}, stale={stale_value})"
1285 );
1286 assert_eq!(
1287 fresh_value, 0.0,
1288 "a fresh press-start yields exactly 0.0 progress"
1289 );
1290 }
1291
1292 #[test]
1293 fn zero_threshold_guard_yields_finite_progress_never_nan() {
1294 // Regression: `hold_threshold_ms(0)` must resolve to a floor of 1ms
1295 // (`ms.max(1)`), never dividing progress by zero — `f64::clamp`
1296 // passes a NaN numerator/zero-denominator result straight through
1297 // rather than clamping it away, which would otherwise poison every
1298 // subsequent observation.
1299 let mut root = progress_root_with_threshold(0);
1300 let mut state = HoldState::default();
1301 let mut sink = NullScene;
1302
1303 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1304 root.paint(&mut sink, ft(0.0));
1305 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // first observation
1306 let p0 = state.progress[0];
1307 assert!(!p0.is_nan(), "zero threshold must never yield NaN progress");
1308 assert!(
1309 (0.0..=1.0).contains(&p0),
1310 "progress stays in the defined [0.0, 1.0] range"
1311 );
1312
1313 // The 1ms floor is reached almost immediately: elapsed marks, and
1314 // the long-press fires on the very next deliverable event, per the
1315 // existing fire-on-move-arrival convention.
1316 root.paint(&mut sink, ft(1.0));
1317 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1318 assert_eq!(
1319 state.long_presses, 1,
1320 "long-press fires almost immediately at the 1ms floor"
1321 );
1322 }
1323}