1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! Gesture recognition — **tier 1** of the brief (§3): a "tap-or-long-press"
//! recogniser that already speaks the **arena**'s vocabulary, so that moving to the
//! real arena (tier 2) is a substitution rather than a rewrite.
//!
//! - The **long press** accepts **eagerly** once the delay elapses: it evicts the
//! tap, and the release that follows is swallowed.
//! - The **tap** accepts **passively**: it wins when nothing evicted it by the time
//! of the release. That is the shell's existing click path.
//! - Movement beyond the **slop** rejects the long press, the gesture having become
//! a drag or a scroll; `Cancel` rejects everything.
//!
//! The machine is **pure** — it never reads a clock, instants come in as parameters
//! — and so is testable down to the tick.
use std::time::Duration;
use web_time::Instant;
use frus_widgets::Point;
/// How long a motionless press must last before the long press is accepted.
pub(crate) const LONG_PRESS_DELAY: Duration = Duration::from_millis(500);
/// The movement, in logical px, beyond which the long press is rejected.
///
/// A long press is a finger gesture, so it uses the finger threshold: a thumb held
/// still for half a second still wanders several pixels, and rejecting on less than
/// that makes the gesture feel broken.
const SLOP: f32 = 18.0;
/// A **normalised** pointer event, mouse or finger: routing's single input — tier 0
/// of the brief, with an explicit `Cancel`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PointerKind {
Down,
Move,
Up,
/// The gesture was interrupted — the app went to the background, the touch was
/// cancelled: give up without a success callback.
Cancel,
}
/// The normalised event: its nature, its **logical** position, and whether the
/// source is touch — touch is what arms finger scrolling.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PointerEvent {
pub kind: PointerKind,
pub position: Point,
pub touch: bool,
}
/// The recogniser's internal states.
#[derive(Debug)]
enum State {
/// No press is being tracked.
Idle,
/// A press is under way and still a candidate for the long press.
Possible { origin: Point, deadline: Instant },
/// The long press **accepted**, that is, fired: the next release is swallowed.
Fired,
/// The candidate was rejected, movement exceeding the slop; the press carries on
/// as a tap or a drag.
Rejected,
}
/// The tap-or-long-press recogniser, one instance per active pointer.
pub(crate) struct PressRecognizer {
state: State,
}
impl PressRecognizer {
pub fn new() -> Self {
Self { state: State::Idle }
}
/// A press: arms tracking when the target cares about the long press
/// (`interested`), and stays inert otherwise.
pub fn down(&mut self, at: Point, now: Instant, interested: bool) {
self.state = if interested {
State::Possible {
origin: at,
deadline: now + LONG_PRESS_DELAY,
}
} else {
State::Rejected
};
}
/// Movement: beyond the slop the long press is rejected, the gesture being a
/// drag — "accepts eagerly on crossing the slop", from the drag's side.
pub fn moved(&mut self, at: Point) {
if let State::Possible { origin, .. } = self.state {
let (dx, dy) = (at.x - origin.x, at.y - origin.y);
if dx * dx + dy * dy > SLOP * SLOP {
self.state = State::Rejected;
}
}
}
/// The deadline at which the long press will fire, if it is still a candidate —
/// hand it to `ControlFlow::WaitUntil` to be woken at exactly the right moment.
pub fn deadline(&self) -> Option<Instant> {
match self.state {
State::Possible { deadline, .. } => Some(deadline),
_ => None,
}
}
/// Has time made the long press fire? `true` exactly once.
pub fn poll(&mut self, now: Instant) -> bool {
if let State::Possible { deadline, .. } = self.state {
if now >= deadline {
self.state = State::Fired;
return true;
}
}
false
}
/// A release: returns `true` when the click must be **swallowed**, a long press
/// having already accepted and evicted the tap.
pub fn up(&mut self) -> bool {
let swallow = matches!(self.state, State::Fired);
self.state = State::Idle;
swallow
}
/// An interruption: give up without emitting anything.
pub fn cancel(&mut self) {
self.state = State::Idle;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn start() -> Instant {
Instant::now()
}
#[test]
fn long_press_fires_after_the_delay_and_swallows_the_release() {
let mut rec = PressRecognizer::new();
let t0 = start();
rec.down(Point::new(10.0, 10.0), t0, true);
assert_eq!(rec.deadline(), Some(t0 + LONG_PRESS_DELAY));
// Before the deadline: nothing.
assert!(!rec.poll(t0 + Duration::from_millis(499)));
// At the deadline: it fires, exactly once.
assert!(rec.poll(t0 + LONG_PRESS_DELAY));
assert!(!rec.poll(t0 + Duration::from_millis(600)));
// The release that follows is swallowed: the long press evicts the tap.
assert!(rec.up());
// And the state is reset.
assert!(rec.deadline().is_none());
}
#[test]
fn release_before_the_delay_is_a_plain_tap() {
let mut rec = PressRecognizer::new();
let t0 = start();
rec.down(Point::new(0.0, 0.0), t0, true);
assert!(!rec.up(), "a tap: the click is not swallowed");
assert!(
!rec.poll(t0 + Duration::from_secs(1)),
"nothing left to fire"
);
}
#[test]
fn movement_beyond_slop_rejects_the_long_press() {
let mut rec = PressRecognizer::new();
let t0 = start();
rec.down(Point::new(0.0, 0.0), t0, true);
// Below the slop: still a candidate. Stated relative to SLOP so the test
// keeps testing the boundary if the threshold is ever retuned.
rec.moved(Point::new(SLOP * 0.3, SLOP * 0.3));
assert!(rec.deadline().is_some());
// Beyond it: rejected, the deadline vanishes, time no longer fires anything.
rec.moved(Point::new(SLOP, SLOP));
assert!(rec.deadline().is_none());
assert!(!rec.poll(t0 + Duration::from_secs(1)));
assert!(!rec.up(), "an ordinary click is still possible");
}
#[test]
fn uninterested_target_never_arms() {
let mut rec = PressRecognizer::new();
rec.down(Point::new(0.0, 0.0), start(), false);
assert!(rec.deadline().is_none());
assert!(!rec.poll(start() + Duration::from_secs(1)));
}
#[test]
fn cancel_abandons_without_firing() {
let mut rec = PressRecognizer::new();
let t0 = start();
rec.down(Point::new(0.0, 0.0), t0, true);
rec.cancel();
assert!(!rec.poll(t0 + Duration::from_secs(1)));
assert!(!rec.up());
}
}