GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/side_swipe.rs (8.7K)
1 //! A two-finger swipe TO THE SIDE, read as a page turn: forward into what a
2 //! row leads to, or back to where the plate was before.
3 //!
4 //! The runner delivers a trackpad's two fingers as wheel events with a pixel
5 //! delta, and a mouse's tilt wheel as horizontal notches. A plate that turns
6 //! pages (a context menu's page rows, a dialog opened from one) feeds every
7 //! wheel event it takes to [`feed`] (a [`SideSwipe`]), which answers once per gesture:
8 //! nothing for a vertical scroll or a small sideways drift, and one
9 //! [`SwipeDir`] when the fingers have travelled far enough to the side to
10 //! mean it. The rest of that gesture turns nothing, so one swipe is one page.
11 //!
12 //! **The direction follows the content**, as a horizontal list does: the
13 //! delta that would scroll a list to show what is to its RIGHT is
14 //! [`SwipeDir::Forward`] — the page a row leads to comes in from the right,
15 //! the side its `›` points to — and the other way is [`SwipeDir::Back`]. The
16 //! compositor applies natural scrolling before the delta reaches the app, so
17 //! under natural scrolling forward is the fingers going LEFT (the page is
18 //! pushed aside) and back is the fingers going right, as on every touch
19 //! surface; under traditional scrolling the two swap with the user's own
20 //! setting, and nothing here asks which it is.
21 //!
22 //! **One gesture is one turn across every plate**: the plate a swipe turns
23 //! is replaced by another under the same fingers, and a recognizer of its
24 //! own would see the rest of the gesture as a new one and turn again. So
25 //! the plates of a thread share one, [`feed`], which only the lift (or a
26 //! pause) readies for the next turn.
27
28 use crate::widget::scroll_motion::{current_scroll_phase, ScrollPhase};
29 use crate::widget::MouseScrollDelta;
30 use std::time::Duration;
31 use web_time::Instant;
32
33 /// How far the fingers travel to the side, in the runner's pixel delta,
34 /// before a swipe is a page turn. A deliberate flick covers this in two or
35 /// three events; a vertical scroll's sideways wander does not reach it.
36 pub const SWIPE_PX: f32 = 40.0;
37 /// How much more sideways than vertical a swipe has to be.
38 pub const SWIPE_SLOPE: f32 = 1.5;
39 /// A pause this long between wheel events ends a gesture, for a source that
40 /// sends no finger lift (a tilt wheel, a trackpad whose stop was dropped).
41 pub const SWIPE_GAP: Duration = Duration::from_millis(250);
42
43 /// Which way a side swipe turns.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 pub enum SwipeDir {
46 /// Into what the row under the pointer leads to.
47 Forward,
48 /// Back to the plate this one was turned to from.
49 Back,
50 }
51
52 /// One gesture's sideways travel. See the module docs.
53 #[derive(Debug, Clone, Default)]
54 pub struct SideSwipe {
55 dx: f32,
56 dy: f32,
57 /// This gesture has turned a page already.
58 fired: bool,
59 last: Option<Instant>,
60 }
61
62 impl SideSwipe {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 /// Feed a wheel event in the phase the runner published for it.
68 pub fn feed(&mut self, delta: &MouseScrollDelta) -> Option<SwipeDir> {
69 self.feed_at(delta, current_scroll_phase(), Instant::now())
70 }
71
72 /// [`Self::feed`] in a phase and at a time the caller names — the tests',
73 /// which run in parallel and cannot share the runner's phase.
74 pub fn feed_at(&mut self, delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> Option<SwipeDir> {
75 if phase == ScrollPhase::FingerEnd {
76 self.reset();
77 return None;
78 }
79 if self.last.is_some_and(|t| now.duration_since(t) > SWIPE_GAP) {
80 self.reset();
81 }
82 self.last = Some(now);
83 let (x, y, whole) = match delta {
84 MouseScrollDelta::LineDelta(x, y) => (*x, *y, true),
85 MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32, false),
86 };
87 self.dx += x;
88 self.dy += y;
89 if self.fired {
90 return None;
91 }
92 // A notch of a tilt wheel is a whole swipe; fingers have to travel.
93 let far = if whole { self.dx.abs() >= 1.0 } else { self.dx.abs() >= SWIPE_PX };
94 if !far || self.dx.abs() < SWIPE_SLOPE * self.dy.abs() {
95 return None;
96 }
97 self.fired = true;
98 // A negative x scrolls a list to show what is to its right.
99 Some(if self.dx < 0.0 { SwipeDir::Forward } else { SwipeDir::Back })
100 }
101
102 /// Forget the gesture in progress.
103 pub fn reset(&mut self) {
104 *self = Self::default();
105 }
106 }
107
108 /// The current window's recognizer (`crate::window_state`), one per window.
109 fn shared<R>(f: impl FnOnce(&std::cell::RefCell<SideSwipe>) -> R) -> R {
110 crate::window_state::with(|w| f(&w.swipe))
111 }
112
113 /// Feed a wheel event to the current window's recognizer (`crate::window_state`) — see the
114 /// module docs.
115 pub fn feed(delta: &MouseScrollDelta) -> Option<SwipeDir> {
116 shared(|s| s.borrow_mut().feed(delta))
117 }
118
119 /// Whether `delta` continues a gesture that has turned a page already. The
120 /// rest of that gesture is the turn's: the plate it turned into may be
121 /// smaller than the one it replaced, and what is under the fingers then is a
122 /// list to scroll or a scene to orbit, which would take it as theirs. A host
123 /// asks this ahead of its own wheel handling and drops the event on `true`;
124 /// the event is fed, so the lift or a pause still ends the gesture.
125 pub fn swallow(delta: &MouseScrollDelta) -> bool {
126 swallow_at(delta, current_scroll_phase(), Instant::now())
127 }
128
129 fn swallow_at(delta: &MouseScrollDelta, phase: ScrollPhase, now: Instant) -> bool {
130 shared(|s| {
131 let mut s = s.borrow_mut();
132 let live = s.fired && phase != ScrollPhase::FingerEnd && s.last.is_some_and(|t| now.duration_since(t) <= SWIPE_GAP);
133 if live {
134 s.feed_at(delta, phase, now);
135 }
136 live
137 })
138 }
139
140 /// End the gesture in progress, as the fingers lifting does: what a test
141 /// that swipes twice calls between, since the runner's phase is one value
142 /// for the whole process and not the test's to set.
143 pub fn end_gesture() {
144 shared(|s| s.borrow_mut().reset());
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::*;
150 use crate::widget::Position;
151
152 fn px(x: f64, y: f64) -> MouseScrollDelta {
153 MouseScrollDelta::PixelDelta(Position { x, y })
154 }
155
156 /// Fingers that travel far enough to the side turn one page, the way the
157 /// content goes; the rest of the gesture turns nothing, and the lift
158 /// readies the next.
159 #[test]
160 fn a_side_swipe_turns_one_page_per_gesture() {
161 let mut s = SideSwipe::new();
162 let t = Instant::now();
163 let f = ScrollPhase::Finger;
164 assert_eq!(s.feed_at(&px(-15.0, 1.0), f, t), None, "not far enough yet");
165 assert_eq!(s.feed_at(&px(-15.0, 1.0), f, t), None);
166 assert_eq!(s.feed_at(&px(-15.0, 2.0), f, t), Some(SwipeDir::Forward));
167 assert_eq!(s.feed_at(&px(-80.0, 0.0), f, t), None, "one turn a gesture");
168 assert_eq!(s.feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t), None);
169 assert_eq!(s.feed_at(&px(60.0, 0.0), f, t), Some(SwipeDir::Back));
170 }
171
172 /// What is left of a gesture after it turned is swallowed, until the
173 /// lift; before it turns, nothing is.
174 #[test]
175 fn the_rest_of_a_turning_gesture_is_swallowed() {
176 end_gesture();
177 let t = Instant::now();
178 let f = ScrollPhase::Finger;
179 assert!(!swallow_at(&px(-30.0, 0.0), f, t), "nothing has turned");
180 shared(|s| s.borrow_mut().feed_at(&px(-50.0, 0.0), f, t));
181 assert!(swallow_at(&px(-30.0, 0.0), f, t), "the rest of the turn");
182 assert!(!swallow_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t), "the lift is not");
183 shared(|s| s.borrow_mut().feed_at(&px(0.0, 0.0), ScrollPhase::FingerEnd, t));
184 assert!(!swallow_at(&px(-30.0, 0.0), f, t), "a new gesture");
185 end_gesture();
186 }
187
188 /// A scroll that is mostly vertical never turns, however far it drifts.
189 #[test]
190 fn a_vertical_scroll_is_not_a_swipe() {
191 let mut s = SideSwipe::new();
192 let t = Instant::now();
193 for _ in 0..20 {
194 assert_eq!(s.feed_at(&px(-10.0, 30.0), ScrollPhase::Finger, t), None);
195 }
196 }
197
198 /// A tilt-wheel notch is a swipe; notches in a burst are one, and a
199 /// pause readies the next.
200 #[test]
201 fn a_tilt_wheel_notch_turns_and_a_pause_ends_the_gesture() {
202 let mut s = SideSwipe::new();
203 let t = Instant::now();
204 let w = ScrollPhase::Wheel;
205 let notch = MouseScrollDelta::LineDelta(-1.0, 0.0);
206 assert_eq!(s.feed_at(¬ch, w, t), Some(SwipeDir::Forward));
207 assert_eq!(s.feed_at(¬ch, w, t + Duration::from_millis(50)), None);
208 let later = t + Duration::from_millis(50) + SWIPE_GAP + Duration::from_millis(1);
209 assert_eq!(s.feed_at(&MouseScrollDelta::LineDelta(1.0, 0.0), w, later), Some(SwipeDir::Back));
210 }
211 }