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