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, SliverConstraints,
114 SliverGeometry, SliverWidget, TOUCH_SLOP, View, 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 /// A detector is a sliver exactly when its child is: the transparent layout
627 /// runs through the child pod, so the sliver face is the detector itself
628 /// and the pod stays sized for this wrapper's own paint and hit test (the
629 /// [`ComponentWidget`](frust_core::ComponentWidget) pattern).
630 fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
631 if self.child.is_sliver() {
632 Some(self)
633 } else {
634 None
635 }
636 }
637
638 crate::authoring::visit_children!(child);
639}
640
641impl SliverWidget for GestureDetectorWidget {
642 /// Pass-through, like [`Widget::layout`]: the child sits at the origin and
643 /// receives `sc` unchanged, under the child pod's layout skip. The box
644 /// layout does nothing beyond the child layout and origin, so the sliver
645 /// layout preserves it exactly; every gesture and event path is untouched.
646 fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
647 self.child.set_origin(Point::ZERO);
648 // A viewport reaches this only through `ChildPod::layout_sliver_child`,
649 // which asks `as_sliver` first, so `None` here means a caller invoked
650 // `layout_sliver` on a wrapper whose `as_sliver` answered `None`: a
651 // contract breach that fails in debug builds; release builds fall back
652 // to an empty sliver.
653 self.child
654 .layout_sliver_child(ctx, sc)
655 .unwrap_or_else(|| {
656 debug_assert!(
657 false,
658 "GestureDetectorWidget::layout_sliver called on a wrapper whose as_sliver answered None"
659 );
660 SliverGeometry::ZERO
661 })
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use frust_core::RenderRoot;
669 use std::any::Any;
670
671 #[derive(Default)]
672 struct TapState {
673 taps: u32,
674 long_presses: u32,
675 }
676
677 /// A trivial full-bleed child that ignores events. Generic over `State` so
678 /// it's reusable across both `TapState` and `HoldState` test fixtures.
679 struct Blank;
680 struct BlankWidget;
681 impl<State: 'static> View<State> for Blank {
682 type Element = BlankWidget;
683 fn build(&self, _ctx: &mut BuildCtx<'_>) -> BlankWidget {
684 BlankWidget
685 }
686 fn rebuild(
687 &self,
688 _prev: &Self,
689 _element: &mut BlankWidget,
690 _ctx: &mut BuildCtx<'_>,
691 ) -> ChangeFlags {
692 ChangeFlags::NONE
693 }
694 }
695 impl Widget for BlankWidget {
696 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
697 bc.max()
698 }
699 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
700 }
701
702 fn widget(with_tap: bool) -> GestureDetectorWidget {
703 let mut view = GestureDetector::<TapState, _>(Blank);
704 if with_tap {
705 view = view.on_tap(|s: &mut TapState| s.taps += 1);
706 }
707 let mut counter = 0u64;
708 View::<TapState>::build(&view, &mut BuildCtx::new(&mut counter))
709 }
710
711 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
712 InputEvent::Pointer(frust_core::PointerEvent {
713 phase,
714 position: Point::new(x, y),
715 button: frust_core::PointerButton::Primary,
716 })
717 }
718
719 fn dispatch(w: &mut GestureDetectorWidget, state: &mut TapState, event: &InputEvent) {
720 let state_any: &mut dyn Any = state;
721 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
722 w.event(&mut ctx, event);
723 }
724
725 #[test]
726 fn press_release_within_slop_is_a_tap() {
727 let mut w = widget(true);
728 let mut state = TapState::default();
729 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
730 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 15.0, 12.0));
731 assert_eq!(state.taps, 1);
732 }
733
734 #[test]
735 fn drag_past_slop_disarms_the_tap() {
736 let mut w = widget(true);
737 let mut state = TapState::default();
738 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
739 // 30 px down: well past TOUCH_SLOP (18).
740 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 10.0, 40.0));
741 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 40.0));
742 assert_eq!(state.taps, 0);
743 }
744
745 #[test]
746 fn cancel_disarms_the_tap() {
747 let mut w = widget(true);
748 let mut state = TapState::default();
749 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
750 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
751 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
752 assert_eq!(state.taps, 0);
753 }
754
755 #[test]
756 fn no_tap_callback_is_a_benign_noop() {
757 let mut w = widget(false);
758 let mut state = TapState::default();
759 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
760 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
761 assert_eq!(state.taps, 0);
762 }
763
764 // --- Long-press ------------------------------------------------------------
765 //
766 // The long-press timer is measured across paints, so these tests drive the
767 // real paint path through `RenderRoot` (the only place a `FrameTime` is
768 // threaded in) with an advancing injected frame clock, exactly like the
769 // scroll/textinput clock tests.
770
771 /// A no-op paint sink for the clock-driven tests.
772 struct NullScene;
773 impl PaintScene for NullScene {
774 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
775 fn draw_text(&mut self, _o: Point, _t: &str) {}
776 }
777
778 /// A frame time `ms` milliseconds from an arbitrary origin.
779 fn ft(ms: f64) -> FrameTime {
780 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
781 }
782
783 /// A `RenderRoot` over a `GestureDetector` wired with both callbacks.
784 fn root() -> RenderRoot<TapState, GestureDetectorView<TapState>> {
785 fn logic(_: &mut TapState) -> GestureDetectorView<TapState> {
786 GestureDetector::<TapState, _>(Blank)
787 .on_tap(|s: &mut TapState| s.taps += 1)
788 .on_long_press(|s: &mut TapState| s.long_presses += 1)
789 }
790 let mut root = RenderRoot::new();
791 let mut state = TapState::default();
792 root.rebuild(&mut logic, &mut state);
793 root.layout(Size::new(100.0, 100.0));
794 root
795 }
796
797 #[test]
798 fn hold_past_threshold_then_up_fires_long_press_only() {
799 let mut root = root();
800 let mut state = TapState::default();
801 let mut sink = NullScene;
802 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
803 root.paint(&mut sink, ft(0.0)); // records press_start = 0
804 root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms → elapsed marked
805 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
806 assert_eq!(state.long_presses, 1, "held press releases as a long-press");
807 assert_eq!(state.taps, 0, "a long-press must not also fire on_tap");
808 }
809
810 /// The same view `root()` builds, standalone — for a test that needs to
811 /// call `RenderRoot::rebuild` a second time against the same retained
812 /// widget (simulating a real shell's next per-frame rebuild).
813 fn long_press_logic(_: &mut TapState) -> GestureDetectorView<TapState> {
814 GestureDetector::<TapState, _>(Blank)
815 .on_tap(|s: &mut TapState| s.taps += 1)
816 .on_long_press(|s: &mut TapState| s.long_presses += 1)
817 }
818
819 #[test]
820 fn stationary_hold_fires_on_long_press_at_the_threshold_with_no_pointer_event() {
821 // The regression this task fixes: before it, a perfectly stationary
822 // finger got nothing until `Up` — paint only *marked* `elapsed`, and
823 // the callback fired on the next pointer event (`Move`/`Up`). Now
824 // paint latches a Housekeeping flush the instant it crosses the
825 // threshold, and the very next `RenderRoot::rebuild` (which every
826 // shell runs before its own paint) drains it and fires — with zero
827 // pointer events anywhere in this test.
828 let mut root = root();
829 let mut state = TapState::default();
830 let mut sink = NullScene;
831 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
832 root.paint(&mut sink, ft(0.0)); // records press_start = 0
833 let crossing = root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms → elapsed marked, flush latched
834 assert!(
835 crossing.needs_frame,
836 "the threshold-crossing paint requests a follow-up frame so a \
837 dirty-driven desktop shell actually reaches the rebuild that fires"
838 );
839 // Paint alone never fires — there is no `EventCtx` in that pass.
840 assert_eq!(state.long_presses, 0, "paint only latches; it never fires");
841
842 // The follow-up frame: every shell runs `rebuild` before `paint`,
843 // and `rebuild` is what drains the flush and dispatches the
844 // `Housekeeping` broadcast this widget fires from.
845 let flags = root.rebuild(&mut long_press_logic, &mut state);
846 assert_eq!(
847 state.long_presses, 1,
848 "a stationary hold fires on_long_press at the threshold, in \
849 wall time, with no pointer event"
850 );
851 assert_eq!(state.taps, 0);
852
853 // End-to-end wake: this consumer mutates only a counter the view never
854 // reads, so the re-diff inside `rebuild` sees nothing — the repaint the
855 // fire asked for exists only because `RenderRoot::rebuild` propagates
856 // the Housekeeping dispatch's `EventOutcome` (see the module docs'
857 // "Long-press firing semantics" and `frust-core`'s flush loop).
858 assert!(
859 flags.needs_paint(),
860 "the fire's `request_redraw` folds into the rebuild's flags, which \
861 is what the mobile frame gate reads"
862 );
863 let after = root.paint(&mut sink, ft(620.0));
864 assert!(
865 after.needs_frame,
866 "and surfaces as `needs_frame` for the desktop `Wait` loop — the \
867 widget itself stops requesting frames once it has fired"
868 );
869 }
870
871 #[test]
872 fn housekeeping_fire_wins_the_race_and_a_later_move_or_up_is_inert() {
873 // If the Housekeeping broadcast reaches the widget before any
874 // further pointer event does, it fires there — and a `Move`/`Up`
875 // arriving afterwards finds `state` already `Fired`, so neither may
876 // double-fire (fire-exactly-once, regardless of which delivery path
877 // won the race).
878 let mut root = root();
879 let mut state = TapState::default();
880 let mut sink = NullScene;
881 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
882 root.paint(&mut sink, ft(0.0));
883 root.paint(&mut sink, ft(600.0)); // elapsed marked, flush latched
884 root.rebuild(&mut long_press_logic, &mut state); // Housekeeping fires here
885 assert_eq!(state.long_presses, 1);
886
887 root.event(&mut state, &ev(PointerPhase::Move, 11.0, 11.0));
888 assert_eq!(
889 state.long_presses, 1,
890 "a post-fire Move must not double-fire"
891 );
892 assert_eq!(state.taps, 0);
893
894 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
895 assert_eq!(state.long_presses, 1, "a post-fire Up must not double-fire");
896 assert_eq!(
897 state.taps, 0,
898 "a post-fire Up must not fall through to on_tap"
899 );
900 }
901
902 #[test]
903 fn drag_before_threshold_then_rebuild_never_fires_via_housekeeping() {
904 // A slop-cancel before the threshold must stay disarmed even across
905 // a later rebuild — the flush is only latched at the
906 // threshold-crossing paint, which a pre-threshold drag never
907 // reaches.
908 let mut root = root();
909 let mut state = TapState::default();
910 let mut sink = NullScene;
911 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
912 root.paint(&mut sink, ft(0.0));
913 root.paint(&mut sink, ft(100.0)); // short of the 500ms threshold
914 root.event(&mut state, &ev(PointerPhase::Move, 10.0, 40.0)); // past slop -> Dragged
915 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 40.0));
916 root.rebuild(&mut long_press_logic, &mut state);
917 assert_eq!(
918 state.long_presses, 0,
919 "a pre-threshold drag never fires via Housekeeping"
920 );
921 assert_eq!(state.taps, 0, "a dragged press is not a tap either");
922 }
923
924 #[test]
925 fn quick_tap_before_threshold_fires_tap_only() {
926 let mut root = root();
927 let mut state = TapState::default();
928 let mut sink = NullScene;
929 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
930 root.paint(&mut sink, ft(0.0));
931 root.paint(&mut sink, ft(100.0)); // 100ms < 500ms → not elapsed
932 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 12.0));
933 assert_eq!(state.taps, 1, "a quick release is a tap");
934 assert_eq!(state.long_presses, 0, "under threshold must not long-press");
935 }
936
937 #[test]
938 fn quick_tap_then_rebuild_never_retroactively_long_presses() {
939 // A quick release before the threshold resolves as a tap; a later
940 // rebuild (which would dispatch Housekeeping if anything were
941 // pending) must not retroactively promote it to a long-press.
942 let mut root = root();
943 let mut state = TapState::default();
944 let mut sink = NullScene;
945 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
946 root.paint(&mut sink, ft(0.0));
947 root.paint(&mut sink, ft(100.0)); // short of the threshold
948 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 12.0));
949 root.rebuild(&mut long_press_logic, &mut state);
950 assert_eq!(state.taps, 1, "the tap already resolved on Up");
951 assert_eq!(
952 state.long_presses, 0,
953 "a subsequent rebuild must not retroactively long-press a released tap"
954 );
955 }
956
957 #[test]
958 fn move_after_threshold_fires_long_press_before_release() {
959 let mut root = root();
960 let mut state = TapState::default();
961 let mut sink = NullScene;
962 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
963 root.paint(&mut sink, ft(0.0));
964 root.paint(&mut sink, ft(600.0)); // elapsed
965 // A tiny jitter (within slop) after the threshold fires immediately.
966 root.event(&mut state, &ev(PointerPhase::Move, 12.0, 11.0));
967 assert_eq!(state.long_presses, 1, "fires on the post-threshold move");
968 // A following Up must not double-fire (long or tap).
969 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 11.0));
970 assert_eq!(
971 state.long_presses, 1,
972 "Up after a fired long-press is inert"
973 );
974 assert_eq!(state.taps, 0);
975 }
976
977 #[test]
978 fn hold_then_drag_then_up_fires_neither() {
979 let mut root = 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)); // elapsed
985 // Drag past the slop wins over the elapsed timer.
986 root.event(&mut state, &ev(PointerPhase::Move, 10.0, 40.0));
987 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 40.0));
988 assert_eq!(state.long_presses, 0, "a drag disarms the long-press");
989 assert_eq!(state.taps, 0, "a drag disarms the tap");
990 }
991
992 #[test]
993 fn cancel_mid_hold_fires_neither_and_leaves_state_untouched() {
994 let mut root = root();
995 let mut state = TapState::default();
996 let mut sink = NullScene;
997 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
998 root.paint(&mut sink, ft(0.0));
999 root.paint(&mut sink, ft(600.0)); // elapsed
1000 root.event(&mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1001 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1002 assert_eq!(state.long_presses, 0, "Cancel fires no long-press");
1003 assert_eq!(state.taps, 0, "Cancel fires no tap");
1004 }
1005
1006 #[test]
1007 fn paint_requests_frames_while_pressed_and_stops_after_up() {
1008 let mut root = root();
1009 let mut state = TapState::default();
1010 let mut sink = NullScene;
1011 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1012 // The timer is pending: paint asks the shell to keep painting.
1013 assert!(
1014 root.paint(&mut sink, ft(0.0)).needs_frame,
1015 "a pending long-press timer requests continuation frames"
1016 );
1017 root.event(&mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1018 // Released: no timer, no continuation frame.
1019 assert!(
1020 !root.paint(&mut sink, ft(16.0)).needs_frame,
1021 "the timer stops requesting frames after Up"
1022 );
1023 }
1024
1025 #[test]
1026 fn only_on_tap_still_behaves_as_before() {
1027 // Regression: a detector with no long-press wired taps exactly as v1.
1028 let mut w = widget(true);
1029 let mut state = TapState::default();
1030 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1031 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1032 assert_eq!(state.taps, 1);
1033 assert_eq!(state.long_presses, 0);
1034 }
1035
1036 // --- Tap-only held press --------------------------------------------------
1037 //
1038 // The event-only `only_on_tap_still_behaves_as_before` above never paints, so
1039 // its clock never crosses the threshold. These drive the real paint path with
1040 // an advancing clock (like the both-handlers tests above) but wire ONLY
1041 // `on_tap`, pinning that a tap-only detector never marks `elapsed` — marking
1042 // it would resolve the press as a would-be long-press with no handler and
1043 // fire NOTHING, swallowing the release.
1044
1045 /// A `RenderRoot` over a `GestureDetector` wired with ONLY `on_tap`.
1046 fn tap_only_root() -> RenderRoot<TapState, GestureDetectorView<TapState>> {
1047 fn logic(_: &mut TapState) -> GestureDetectorView<TapState> {
1048 GestureDetector::<TapState, _>(Blank).on_tap(|s: &mut TapState| s.taps += 1)
1049 }
1050 let mut root = RenderRoot::new();
1051 let mut state = TapState::default();
1052 root.rebuild(&mut logic, &mut state);
1053 root.layout(Size::new(100.0, 100.0));
1054 root
1055 }
1056
1057 #[test]
1058 fn tap_only_hold_past_threshold_then_up_still_fires_tap() {
1059 // (a) hold past the threshold, release in-bounds → on_tap fires.
1060 let mut root = tap_only_root();
1061 let mut state = TapState::default();
1062 let mut sink = NullScene;
1063 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1064 root.paint(&mut sink, ft(0.0)); // records press_start = 0
1065 root.paint(&mut sink, ft(600.0)); // 600ms >= 500ms, but no long-press wired
1066 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
1067 assert_eq!(
1068 state.taps, 1,
1069 "a held press with no on_long_press still taps on release"
1070 );
1071 assert_eq!(state.long_presses, 0);
1072 }
1073
1074 #[test]
1075 fn tap_only_hold_then_within_slop_move_taps_on_release_not_on_move() {
1076 // (b) hold past the threshold, a within-slop Move arrives after elapsed →
1077 // nothing at the Move; on_tap still fires on release.
1078 let mut root = tap_only_root();
1079 let mut state = TapState::default();
1080 let mut sink = NullScene;
1081 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1082 root.paint(&mut sink, ft(0.0));
1083 root.paint(&mut sink, ft(600.0));
1084 root.event(&mut state, &ev(PointerPhase::Move, 12.0, 11.0));
1085 assert_eq!(
1086 state.taps, 0,
1087 "a tap-only detector fires nothing on the move"
1088 );
1089 assert_eq!(state.long_presses, 0);
1090 root.event(&mut state, &ev(PointerPhase::Up, 12.0, 11.0));
1091 assert_eq!(state.taps, 1, "the tap fires on the in-bounds release");
1092 assert_eq!(state.long_presses, 0);
1093 }
1094
1095 #[test]
1096 fn tap_only_press_requests_no_continuation_frames() {
1097 // The battery win: a tap-only detector runs no long-press timer, so a
1098 // pending press must not keep the dirty-driven shell painting.
1099 let mut root = tap_only_root();
1100 let mut state = TapState::default();
1101 let mut sink = NullScene;
1102 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1103 assert!(
1104 !root.paint(&mut sink, ft(0.0)).needs_frame,
1105 "a tap-only press has no timer, so it requests no continuation frames"
1106 );
1107 }
1108
1109 #[test]
1110 fn both_handlers_long_press_behavior_unchanged() {
1111 // (c) with both handlers wired, a held-past-threshold release is still a
1112 // long-press and never also a tap — the fix must not regress this.
1113 let mut root = root();
1114 let mut state = TapState::default();
1115 let mut sink = NullScene;
1116 root.event(&mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1117 root.paint(&mut sink, ft(0.0));
1118 root.paint(&mut sink, ft(600.0));
1119 root.event(&mut state, &ev(PointerPhase::Up, 11.0, 11.0));
1120 assert_eq!(state.long_presses, 1, "both wired: hold still long-presses");
1121 assert_eq!(state.taps, 0, "both wired: a long-press never also taps");
1122 }
1123
1124 // --- Hold-progress observation ---------------------------------------------
1125 //
1126 // `on_hold_progress` rides the same paint-clock timer as `on_long_press`:
1127 // paint records the latest 0.0..=1.0 observation, and it's delivered on the
1128 // next pointer event carrying a mutable `EventCtx` — an in-slop `Move` (the
1129 // same fire-on-move-arrival opportunity `on_long_press` uses) or `Up`.
1130
1131 #[derive(Default)]
1132 struct HoldState {
1133 taps: u32,
1134 long_presses: u32,
1135 progress: Vec<f64>,
1136 }
1137
1138 fn hold_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1139 InputEvent::Pointer(frust_core::PointerEvent {
1140 phase,
1141 position: Point::new(x, y),
1142 button: frust_core::PointerButton::Primary,
1143 })
1144 }
1145
1146 /// A `RenderRoot` over a `GestureDetector` wired with all three callbacks,
1147 /// at the default [`LONG_PRESS_MS`] threshold.
1148 fn progress_root() -> RenderRoot<HoldState, GestureDetectorView<HoldState>> {
1149 fn logic(_: &mut HoldState) -> GestureDetectorView<HoldState> {
1150 GestureDetector::<HoldState, _>(Blank)
1151 .on_tap(|s: &mut HoldState| s.taps += 1)
1152 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1153 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1154 }
1155 let mut root = RenderRoot::new();
1156 let mut state = HoldState::default();
1157 root.rebuild(&mut logic, &mut state);
1158 root.layout(Size::new(100.0, 100.0));
1159 root
1160 }
1161
1162 /// Like [`progress_root`], but with an overridden `hold_threshold_ms`.
1163 fn progress_root_with_threshold(
1164 ms: u64,
1165 ) -> RenderRoot<HoldState, GestureDetectorView<HoldState>> {
1166 fn logic(state: &mut HoldState, ms: u64) -> GestureDetectorView<HoldState> {
1167 let _ = state;
1168 GestureDetector::<HoldState, _>(Blank)
1169 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1170 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1171 .hold_threshold_ms(ms)
1172 }
1173 let mut root = RenderRoot::new();
1174 let mut state = HoldState::default();
1175 root.rebuild(&mut |s: &mut HoldState| logic(s, ms), &mut state);
1176 root.layout(Size::new(100.0, 100.0));
1177 root
1178 }
1179
1180 #[test]
1181 fn hold_progress_increases_monotonically_and_long_press_still_fires() {
1182 // Criterion 1: progress observations increase monotonically 0->1;
1183 // on_long_press still fires per its existing contract.
1184 let mut root = progress_root();
1185 let mut state = HoldState::default();
1186 let mut sink = NullScene;
1187 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1188 root.paint(&mut sink, ft(0.0));
1189 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // in-slop jitter
1190 root.paint(&mut sink, ft(150.0));
1191 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1192 root.paint(&mut sink, ft(300.0));
1193 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 11.0));
1194 root.paint(&mut sink, ft(500.0)); // elapsed marked (500ms threshold)
1195 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 11.0)); // fires long-press
1196
1197 assert_eq!(
1198 state.progress,
1199 vec![0.0, 0.3, 0.6, 1.0],
1200 "progress observations increase monotonically 0..1"
1201 );
1202 assert_eq!(state.long_presses, 1, "threshold reached: long-press fires");
1203 assert_eq!(state.taps, 0);
1204
1205 // A following Up must not double-fire anything.
1206 root.event(&mut state, &hold_ev(PointerPhase::Up, 11.0, 11.0));
1207 assert_eq!(state.long_presses, 1);
1208 assert_eq!(
1209 state.progress.len(),
1210 4,
1211 "Up after a fired long-press adds no observation"
1212 );
1213 }
1214
1215 /// The same view [`progress_root`] builds, standalone — for a test that
1216 /// needs to call `RenderRoot::rebuild` a second time against the same
1217 /// retained widget (simulating a real shell's next per-frame rebuild).
1218 fn progress_logic(_: &mut HoldState) -> GestureDetectorView<HoldState> {
1219 GestureDetector::<HoldState, _>(Blank)
1220 .on_tap(|s: &mut HoldState| s.taps += 1)
1221 .on_long_press(|s: &mut HoldState| s.long_presses += 1)
1222 .on_hold_progress(|s: &mut HoldState, p| s.progress.push(p))
1223 }
1224
1225 #[test]
1226 fn housekeeping_fire_delivers_the_paired_final_progress_and_a_later_drag_is_inert() {
1227 // A perfectly stationary hold with both `on_long_press` and
1228 // `on_hold_progress` wired: the Housekeeping-triggered fire delivers
1229 // both in the same pass (matching the paired delivery the
1230 // Move-arrival path has always given), and — since `state` is
1231 // `Fired`, not `Pressed`, afterwards — a subsequent Move past the
1232 // slop must not be mistaken for the Pressed→Dragged transition
1233 // (which would otherwise re-deliver a spurious final `0.0`); it just
1234 // forwards to the child, e.g. so an underlying scrollable can keep
1235 // tracking the drag.
1236 let mut root = progress_root();
1237 let mut state = HoldState::default();
1238 let mut sink = NullScene;
1239 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1240 root.paint(&mut sink, ft(0.0));
1241 root.paint(&mut sink, ft(500.0)); // elapsed marked, flush latched
1242 root.rebuild(&mut progress_logic, &mut state); // Housekeeping fires here
1243 assert_eq!(state.long_presses, 1);
1244 assert_eq!(
1245 state.progress,
1246 vec![1.0],
1247 "the paired final progress observation rides the same fire"
1248 );
1249
1250 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 40.0)); // past TOUCH_SLOP
1251 assert_eq!(state.long_presses, 1, "no double-fire on a post-fire drag");
1252 assert_eq!(
1253 state.progress,
1254 vec![1.0],
1255 "a post-fire Move delivers no further progress observation"
1256 );
1257 }
1258
1259 #[test]
1260 fn early_lift_delivers_a_final_zero_observation_and_no_long_press() {
1261 // Criterion 2: early lift at ~50% -> a final 0.0 observation; no long-press.
1262 let mut root = progress_root();
1263 let mut state = HoldState::default();
1264 let mut sink = NullScene;
1265 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1266 root.paint(&mut sink, ft(0.0));
1267 root.paint(&mut sink, ft(250.0)); // ~50% of the 500ms threshold, not elapsed
1268 root.event(&mut state, &hold_ev(PointerPhase::Up, 11.0, 11.0));
1269
1270 assert_eq!(
1271 state.progress.last().copied(),
1272 Some(0.0),
1273 "an early lift's final observation resets to exactly 0.0"
1274 );
1275 assert_eq!(state.long_presses, 0, "early lift never long-presses");
1276 }
1277
1278 #[test]
1279 fn move_past_slop_mid_hold_delivers_a_final_zero_and_no_long_press() {
1280 // Criterion 3: move past slop mid-hold -> cancel + 0.0; no long-press fire.
1281 let mut root = progress_root();
1282 let mut state = HoldState::default();
1283 let mut sink = NullScene;
1284 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1285 root.paint(&mut sink, ft(0.0));
1286 root.paint(&mut sink, ft(300.0)); // 60% held, not elapsed
1287 // 30px move: well past TOUCH_SLOP (18) -> becomes a drag.
1288 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 40.0));
1289 root.event(&mut state, &hold_ev(PointerPhase::Up, 10.0, 40.0));
1290
1291 assert_eq!(
1292 state.progress.last().copied(),
1293 Some(0.0),
1294 "a drag past slop delivers a final 0.0 observation"
1295 );
1296 assert_eq!(state.long_presses, 0);
1297 assert_eq!(state.taps, 0);
1298 }
1299
1300 #[test]
1301 fn hold_threshold_ms_override_is_respected_by_both_callbacks() {
1302 // Criterion 4: hold_threshold_ms(700) respected by both callbacks.
1303 let mut root = progress_root_with_threshold(700);
1304 let mut state = HoldState::default();
1305 let mut sink = NullScene;
1306 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1307 root.paint(&mut sink, ft(0.0));
1308 root.paint(&mut sink, ft(600.0)); // past the default 500ms, short of 700ms
1309 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0));
1310 assert_eq!(
1311 state.long_presses, 0,
1312 "the 700ms override must not fire long-press at 600ms"
1313 );
1314 assert!(
1315 (state.progress.last().copied().unwrap() - 600.0 / 700.0).abs() < 1e-9,
1316 "progress reflects the overridden 700ms threshold, not the 500ms default"
1317 );
1318
1319 root.paint(&mut sink, ft(700.0)); // now past the overridden threshold
1320 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1321 assert_eq!(
1322 state.long_presses, 1,
1323 "the 700ms override still fires long-press once reached"
1324 );
1325 assert_eq!(state.progress.last().copied(), Some(1.0));
1326 }
1327
1328 #[test]
1329 fn no_hold_progress_handler_is_a_benign_noop() {
1330 // Criterion 5: on_tap/on_long_press-only users unaffected.
1331 let mut w = widget(true);
1332 let mut state = TapState::default();
1333 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1334 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 11.0, 11.0));
1335 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1336 assert_eq!(state.taps, 1);
1337 assert_eq!(state.long_presses, 0);
1338 }
1339
1340 // --- Cancel staleness gap + zero-threshold NaN guard ---------------------
1341
1342 #[test]
1343 fn cancel_leaves_progress_stale_until_fresh_down_cycle_delivers_clean_value() {
1344 // Regression for the Cancel staleness gap (see gesture.rs module
1345 // docs): a platform Cancel delivers NO final observation, so the
1346 // consumer's last-observed progress stays whatever it was mid-hold.
1347 // A fresh Down-cycle's first observation must start low again, never
1348 // resuming from the stale value Cancel left behind — the reset idiom
1349 // works at the widget contract level.
1350 let mut root = progress_root();
1351 let mut state = HoldState::default();
1352 let mut sink = NullScene;
1353
1354 // Cycle 1: hold to ~30% progress, then Cancel (gesture steal).
1355 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1356 root.paint(&mut sink, ft(0.0));
1357 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // delivers 0.0
1358 root.paint(&mut sink, ft(150.0)); // 30% of the 500ms default threshold
1359 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0)); // delivers 0.3
1360 let before_cancel_len = state.progress.len();
1361 let stale_value = *state.progress.last().unwrap();
1362 assert!(
1363 stale_value > 0.0,
1364 "sanity: mid-hold progress is non-zero before Cancel"
1365 );
1366
1367 root.event(&mut state, &hold_ev(PointerPhase::Cancel, 11.0, 10.0));
1368 assert_eq!(
1369 state.progress.len(),
1370 before_cancel_len,
1371 "Cancel delivers no observation — the Cancel-never-mutates-state convention"
1372 );
1373
1374 // Cycle 2: a fresh Down cycle. The first delivered observation must
1375 // be a low, fresh value — never the stale value Cancel left behind.
1376 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1377 root.paint(&mut sink, ft(150.0)); // press_start re-anchors here; elapsed_ms = 0
1378 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0));
1379 let fresh_value = *state.progress.last().unwrap();
1380 assert!(
1381 fresh_value < stale_value,
1382 "a fresh Down-cycle's first observation starts low, self-correcting the stale \
1383 ring (fresh={fresh_value}, stale={stale_value})"
1384 );
1385 assert_eq!(
1386 fresh_value, 0.0,
1387 "a fresh press-start yields exactly 0.0 progress"
1388 );
1389 }
1390
1391 #[test]
1392 fn zero_threshold_guard_yields_finite_progress_never_nan() {
1393 // Regression: `hold_threshold_ms(0)` must resolve to a floor of 1ms
1394 // (`ms.max(1)`), never dividing progress by zero — `f64::clamp`
1395 // passes a NaN numerator/zero-denominator result straight through
1396 // rather than clamping it away, which would otherwise poison every
1397 // subsequent observation.
1398 let mut root = progress_root_with_threshold(0);
1399 let mut state = HoldState::default();
1400 let mut sink = NullScene;
1401
1402 root.event(&mut state, &hold_ev(PointerPhase::Down, 10.0, 10.0));
1403 root.paint(&mut sink, ft(0.0));
1404 root.event(&mut state, &hold_ev(PointerPhase::Move, 10.0, 10.0)); // first observation
1405 let p0 = state.progress[0];
1406 assert!(!p0.is_nan(), "zero threshold must never yield NaN progress");
1407 assert!(
1408 (0.0..=1.0).contains(&p0),
1409 "progress stays in the defined [0.0, 1.0] range"
1410 );
1411
1412 // The 1ms floor is reached almost immediately: elapsed marks, and
1413 // the long-press fires on the very next deliverable event, per the
1414 // existing fire-on-move-arrival convention.
1415 root.paint(&mut sink, ft(1.0));
1416 root.event(&mut state, &hold_ev(PointerPhase::Move, 11.0, 10.0));
1417 assert_eq!(
1418 state.long_presses, 1,
1419 "long-press fires almost immediately at the 1ms floor"
1420 );
1421 }
1422
1423 // --- HoldTracker -------------------------------------------------------------
1424
1425 #[test]
1426 fn a_hold_tracker_anchors_on_its_first_observation() {
1427 let mut hold = HoldTracker::new(500.0);
1428 // The first read records the epoch, whatever the clock says.
1429 assert_eq!(hold.progress(ft(1_000.0)), 0.0);
1430 assert!(!hold.fired(ft(1_000.0)));
1431 assert_eq!(hold.progress(ft(1_250.0)), 0.5);
1432 assert!(!hold.fired(ft(1_499.0)));
1433 assert!(hold.fired(ft(1_500.0)), "fires at the threshold, inclusive");
1434 assert_eq!(hold.progress(ft(9_000.0)), 1.0, "progress clamps at 1.0");
1435 }
1436
1437 #[test]
1438 fn cancelling_a_hold_tracker_re_anchors_the_next_press() {
1439 let mut hold = HoldTracker::new(500.0);
1440 assert!(!hold.fired(ft(0.0)));
1441 assert!(hold.fired(ft(600.0)));
1442 hold.cancel();
1443 assert!(!hold.fired(ft(700.0)), "a fresh epoch, not the old one");
1444 assert!(hold.fired(ft(1_200.0)));
1445 }
1446
1447 #[test]
1448 fn a_threshold_change_mid_hold_keeps_the_epoch() {
1449 let mut hold = HoldTracker::new(500.0);
1450 assert!(!hold.fired(ft(0.0)));
1451 hold.set_threshold_ms(200.0);
1452 assert!(hold.fired(ft(250.0)));
1453 }
1454}