git.lucas.co / cce-ui
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(&notch, w, t), Some(SwipeDir::Forward));
207         assert_eq!(s.feed_at(&notch, 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 }