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