Skip to main content

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}