From 676d5897aeec3419e5d0c345d7fe34f9c606036a Mon Sep 17 00:00:00 2001 From: LIU Xinyu Date: Mon, 15 Jun 2026 09:55:49 +0800 Subject: [PATCH] feat: kinetic scrolling on wayland (#54) * wayland: add kinetic scroll controller * wayland: wire kinetic scrolling into pointer events * wayland: cancel kinetic scrolling on pointer and gesture interruption * wayland: add kinetic scroll tests --- crates/gpui_linux/src/linux/wayland.rs | 1 + crates/gpui_linux/src/linux/wayland/client.rs | 95 +++- crates/gpui_linux/src/linux/wayland/scroll.rs | 428 ++++++++++++++++++ 3 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 crates/gpui_linux/src/linux/wayland/scroll.rs diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index 3e90688d1b..a69efb5c35 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -2,6 +2,7 @@ mod client; mod clipboard; mod cursor; mod display; +mod scroll; mod serial; mod window; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index f360ede462..c5e453b484 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -37,7 +37,7 @@ use wayland_client::{ }, }; use wayland_protocols::wp::pointer_gestures::zv1::client::{ - zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, + zwp_pointer_gesture_hold_v1, zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, }; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, @@ -74,6 +74,7 @@ use xkbcommon::xkb::{self, KEYMAP_COMPILE_NO_FLAGS, Keycode}; use super::{ display::WaylandDisplay, + scroll::KineticScrollController, window::{ImeInput, WaylandWindowStatePtr}, }; @@ -221,6 +222,7 @@ pub(crate) struct WaylandClientState { wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, pinch_gesture: Option, + hold_gesture: Option, pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, @@ -248,6 +250,7 @@ pub(crate) struct WaylandClientState { pub mouse_location: Option>, continuous_scroll_delta: Option>, discrete_scroll_delta: Option>, + kinetic_scroll: KineticScrollController, vertical_modifier: f32, horizontal_modifier: f32, scroll_event_received: bool, @@ -673,6 +676,7 @@ impl WaylandClient { wl_pointer: None, wl_keyboard: None, pinch_gesture: None, + hold_gesture: None, pinch_scale: 1.0, cursor_shape_device: None, data_device, @@ -719,6 +723,7 @@ impl WaylandClient { mouse_location: None, continuous_scroll_delta: None, discrete_scroll_delta: None, + kinetic_scroll: KineticScrollController::new(), vertical_modifier: -1.0, horizontal_modifier: -1.0, button_pressed: None, @@ -1186,10 +1191,18 @@ impl Dispatch for WaylandClientStatePtr { let Some(window) = get_window(&mut state, surface_id) else { return; }; + let kinetic_input = if let wl_callback::Event::Done { .. } = event { + state.kinetic_scroll.tick(&window) + } else { + None + }; drop(state); if let wl_callback::Event::Done { .. } = event { window.frame(); + if let Some((window, input)) = kinetic_input { + window.handle_input(input); + } } } } @@ -1440,6 +1453,13 @@ impl Dispatch for WaylandClientStatePtr { }, ); + state.hold_gesture = state.globals.gesture_manager.as_ref().and_then( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + (gesture_manager.version() >= 3) + .then(|| gesture_manager.get_hold_gesture(&pointer, qh, ())) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1856,6 +1876,7 @@ impl Dispatch for WaylandClientStatePtr { } state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); state.restore_cursor_after_hide(); + let kinetic_input = state.kinetic_scroll.cancel(); if let Some(window) = state.mouse_focused_window.clone() { if window.is_blocked() { @@ -1895,6 +1916,9 @@ impl Dispatch for WaylandClientStatePtr { modifiers: state.modifiers, }); drop(state); + if let Some((window, input)) = kinetic_input { + window.handle_input(input); + } window.handle_input(input); } } @@ -1996,6 +2020,9 @@ impl Dispatch for WaylandClientStatePtr { if state.axis_source == AxisSource::Wheel { return; } + if state.axis_source == AxisSource::Finger { + state.kinetic_scroll.start_finger_scroll(); + } let axis = if state.modifiers.shift { wl_pointer::Axis::HorizontalScroll } else { @@ -2076,21 +2103,48 @@ impl Dispatch for WaylandClientStatePtr { _ => unreachable!(), } } + wl_pointer::Event::AxisStop { .. } => { + if state.axis_source == AxisSource::Finger + && state.kinetic_scroll.stop_finger_scroll() + { + state.scroll_event_received = true; + } + } wl_pointer::Event::Frame => { if state.scroll_event_received { state.scroll_event_received = false; let continuous = state.continuous_scroll_delta.take(); let discrete = state.discrete_scroll_delta.take(); if let Some(continuous) = continuous { + let touch_phase = state.kinetic_scroll.touch_phase(); + if state.axis_source == AxisSource::Finger { + state + .kinetic_scroll + .record_delta(Instant::now(), continuous); + } + let mut kinetic_input = None; + if state.kinetic_scroll.has_pending_stop() { + if let (Some(window), Some(position)) = + (state.mouse_focused_window.clone(), state.mouse_location) + { + let modifiers = state.modifiers; + kinetic_input = state + .kinetic_scroll + .finish_pending_stop(window, position, modifiers); + } + } if let Some(window) = state.mouse_focused_window.clone() { let input = PlatformInput::ScrollWheel(ScrollWheelEvent { position: state.mouse_location.unwrap(), delta: ScrollDelta::Pixels(continuous), modifiers: state.modifiers, - touch_phase: TouchPhase::Moved, + touch_phase, }); drop(state); window.handle_input(input); + if let Some((window, input)) = kinetic_input { + window.handle_input(input); + } } } else if let Some(discrete) = discrete && let Some(window) = state.mouse_focused_window.clone() @@ -2103,6 +2157,18 @@ impl Dispatch for WaylandClientStatePtr { }); drop(state); window.handle_input(input); + } else if state.kinetic_scroll.has_pending_stop() + && let (Some(window), Some(position)) = + (state.mouse_focused_window.clone(), state.mouse_location) + { + let modifiers = state.modifiers; + if let Some((window, input)) = state + .kinetic_scroll + .finish_pending_stop(window, position, modifiers) + { + drop(state); + window.handle_input(input); + } } } } @@ -2151,6 +2217,11 @@ impl Dispatch surface: _, fingers: _, } => { + if let Some((window, input)) = state.kinetic_scroll.cancel() { + drop(state); + window.handle_input(input); + state = client.borrow_mut(); + } state.pinch_scale = 1.0; let input = PlatformInput::Pinch(PinchEvent { position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), @@ -2196,6 +2267,26 @@ impl Dispatch } } +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_hold_v1::ZwpPointerGestureHoldV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let zwp_pointer_gesture_hold_v1::Event::Begin { .. } = event { + let client = this.get_client(); + let mut state = client.borrow_mut(); + if let Some((window, input)) = state.kinetic_scroll.cancel() { + drop(state); + window.handle_input(input); + } + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, diff --git a/crates/gpui_linux/src/linux/wayland/scroll.rs b/crates/gpui_linux/src/linux/wayland/scroll.rs new file mode 100644 index 0000000000..5a57f80196 --- /dev/null +++ b/crates/gpui_linux/src/linux/wayland/scroll.rs @@ -0,0 +1,428 @@ +use std::{ + collections::VecDeque, + time::{Duration, Instant}, +}; + +use super::window::WaylandWindowStatePtr; +use gpui::{ + Modifiers, Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchPhase, point, px, +}; + +const KINETIC_SCROLL_HISTORY_WINDOW: Duration = Duration::from_millis(150); +const KINETIC_SCROLL_FRICTION: f32 = 4.0; +const KINETIC_SCROLL_STOP_VELOCITY: f32 = 5.0; +const KINETIC_SCROLL_MAX_VELOCITY: f32 = 6000.0; + +pub(crate) struct KineticScrollController { + history: KineticScrollHistory, + id: u64, + scroll: Option, + finger_active: bool, + finger_start_pending: bool, + finger_stop_pending: bool, +} + +struct KineticScrollHistory { + entries: VecDeque<(Instant, Point)>, + displacement: Point, +} + +struct KineticScroll { + id: u64, + window: WaylandWindowStatePtr, + position: Point, + modifiers: Modifiers, + velocity: Point, + last_time: Instant, +} + +impl KineticScrollController { + pub(crate) fn new() -> Self { + Self { + history: KineticScrollHistory::new(), + id: 0, + scroll: None, + finger_active: false, + finger_start_pending: false, + finger_stop_pending: false, + } + } + + pub(crate) fn start_finger_scroll(&mut self) { + self.id += 1; + self.scroll = None; + self.finger_stop_pending = false; + if !self.finger_active { + self.finger_active = true; + self.finger_start_pending = true; + self.history.clear(); + } + } + + pub(crate) fn stop_finger_scroll(&mut self) -> bool { + if self.finger_active { + self.finger_stop_pending = true; + true + } else { + false + } + } + + pub(crate) fn touch_phase(&mut self) -> TouchPhase { + if self.finger_start_pending { + self.finger_start_pending = false; + TouchPhase::Started + } else { + TouchPhase::Moved + } + } + + pub(crate) fn record_delta(&mut self, time: Instant, delta: Point) { + self.history.push(time, delta); + } + + pub(crate) fn has_pending_stop(&self) -> bool { + self.finger_stop_pending + } + + pub(crate) fn finish_pending_stop( + &mut self, + window: WaylandWindowStatePtr, + position: Point, + modifiers: Modifiers, + ) -> Option<(WaylandWindowStatePtr, PlatformInput)> { + self.finger_stop_pending = false; + self.finger_active = false; + self.finger_start_pending = false; + let velocity = self.history.velocity(Instant::now()); + self.history.clear(); + self.start(window, position, modifiers, velocity) + } + + pub(crate) fn tick( + &mut self, + window: &WaylandWindowStatePtr, + ) -> Option<(WaylandWindowStatePtr, PlatformInput)> { + let kinetic_scroll = self.scroll.as_mut()?; + if kinetic_scroll.id != self.id || !kinetic_scroll.window.ptr_eq(window) { + return None; + } + + let now = Instant::now(); + let elapsed = now + .duration_since(kinetic_scroll.last_time) + .as_secs_f32() + .min(0.05); + kinetic_scroll.last_time = now; + + let delta = point( + px(f32::from(kinetic_scroll.velocity.x) * elapsed), + px(f32::from(kinetic_scroll.velocity.y) * elapsed), + ); + let velocity_multiplier = (-KINETIC_SCROLL_FRICTION * elapsed).exp(); + kinetic_scroll.velocity.x = px(f32::from(kinetic_scroll.velocity.x) * velocity_multiplier); + kinetic_scroll.velocity.y = px(f32::from(kinetic_scroll.velocity.y) * velocity_multiplier); + + let finished = is_kinetic_scroll_stopped(kinetic_scroll.velocity); + + let input = PlatformInput::ScrollWheel(ScrollWheelEvent { + position: kinetic_scroll.position, + delta: ScrollDelta::Pixels(delta), + modifiers: kinetic_scroll.modifiers, + touch_phase: if finished { + TouchPhase::Ended + } else { + TouchPhase::Moved + }, + }); + + let window = kinetic_scroll.window.clone(); + if finished { + self.finger_active = false; + self.finger_start_pending = false; + self.finger_stop_pending = false; + self.scroll = None; + self.history.clear(); + } + + Some((window, input)) + } + + pub(crate) fn cancel(&mut self) -> Option<(WaylandWindowStatePtr, PlatformInput)> { + let kinetic_scroll = self.scroll.take()?; + + self.id += 1; + self.finger_active = false; + self.finger_start_pending = false; + self.finger_stop_pending = false; + self.history.clear(); + + Some(( + kinetic_scroll.window, + PlatformInput::ScrollWheel(ScrollWheelEvent { + position: kinetic_scroll.position, + delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), + modifiers: kinetic_scroll.modifiers, + touch_phase: TouchPhase::Ended, + }), + )) + } + + fn start( + &mut self, + window: WaylandWindowStatePtr, + position: Point, + modifiers: Modifiers, + velocity: Point, + ) -> Option<(WaylandWindowStatePtr, PlatformInput)> { + self.id += 1; + if is_kinetic_scroll_stopped(velocity) { + return Some(( + window, + PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), + modifiers, + touch_phase: TouchPhase::Ended, + }), + )); + } + + let id = self.id; + self.scroll = Some(KineticScroll { + id, + window, + position, + modifiers, + velocity, + last_time: Instant::now(), + }); + None + } +} + +impl KineticScrollHistory { + fn new() -> Self { + Self { + entries: VecDeque::new(), + displacement: point(px(0.0), px(0.0)), + } + } + + fn push(&mut self, time: Instant, delta: Point) { + self.entries.push_back((time, delta)); + self.displacement += delta; + let cutoff = time - KINETIC_SCROLL_HISTORY_WINDOW; + while self + .entries + .front() + .is_some_and(|(entry_time, _)| *entry_time < cutoff) + { + let delta = self + .entries + .pop_front() + .map(|(_, delta)| delta) + .unwrap_or(point(px(0.0), px(0.0))); + self.displacement -= delta; + } + } + + fn velocity(&self, now: Instant) -> Point { + let Some((first_time, _)) = self.entries.front() else { + return point(px(0.0), px(0.0)); + }; + + let duration = now.duration_since(*first_time).as_secs_f32(); + if duration == 0.0 { + return point(px(0.0), px(0.0)); + } + + let delta = self.displacement; + + point( + px((f32::from(delta.x) / duration) + .clamp(-KINETIC_SCROLL_MAX_VELOCITY, KINETIC_SCROLL_MAX_VELOCITY)), + px((f32::from(delta.y) / duration) + .clamp(-KINETIC_SCROLL_MAX_VELOCITY, KINETIC_SCROLL_MAX_VELOCITY)), + ) + } + + fn clear(&mut self) { + self.entries.clear(); + self.displacement = point(px(0.0), px(0.0)); + } +} + +fn is_kinetic_scroll_stopped(velocity: Point) -> bool { + f32::from(velocity.x).abs() < KINETIC_SCROLL_STOP_VELOCITY + && f32::from(velocity.y).abs() < KINETIC_SCROLL_STOP_VELOCITY +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_kinetic_scroll_stopped() { + assert!(is_kinetic_scroll_stopped(point(px(0.0), px(0.0)))); + assert!(is_kinetic_scroll_stopped(point(px(4.9), px(4.9)))); + assert!(is_kinetic_scroll_stopped(point(px(-4.9), px(4.9)))); + assert!(!is_kinetic_scroll_stopped(point(px(5.0), px(0.0)))); + assert!(!is_kinetic_scroll_stopped(point(px(0.0), px(5.0)))); + assert!(!is_kinetic_scroll_stopped(point(px(100.0), px(100.0)))); + } + + #[test] + fn test_history_velocity_empty() { + let history = KineticScrollHistory::new(); + let velocity = history.velocity(Instant::now()); + assert_eq!(f32::from(velocity.x), 0.0); + assert_eq!(f32::from(velocity.y), 0.0); + } + + #[test] + fn test_history_velocity_single_entry() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push( + now - Duration::from_millis(100), + point(px(100.0), px(200.0)), + ); + let velocity = history.velocity(now); + assert!((f32::from(velocity.x) - 1000.0).abs() < 1.0); + assert!((f32::from(velocity.y) - 2000.0).abs() < 1.0); + } + + #[test] + fn test_history_velocity_multiple_entries() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push(now - Duration::from_millis(100), point(px(50.0), px(0.0))); + history.push(now - Duration::from_millis(50), point(px(50.0), px(100.0))); + let velocity = history.velocity(now); + assert!((f32::from(velocity.x) - 1000.0).abs() < 1.0); + assert!((f32::from(velocity.y) - 1000.0).abs() < 1.0); + } + + #[test] + fn test_history_velocity_zero_duration() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push(now, point(px(100.0), px(200.0))); + let velocity = history.velocity(now); + assert_eq!(f32::from(velocity.x), 0.0); + assert_eq!(f32::from(velocity.y), 0.0); + } + + #[test] + fn test_history_velocity_clamped() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push( + now - Duration::from_millis(1), + point(px(60000.0), px(-60000.0)), + ); + let velocity = history.velocity(now); + assert_eq!(f32::from(velocity.x), KINETIC_SCROLL_MAX_VELOCITY); + assert_eq!(f32::from(velocity.y), -KINETIC_SCROLL_MAX_VELOCITY); + } + + #[test] + fn test_history_prunes_old_entries() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push( + now - Duration::from_millis(300), + point(px(9999.0), px(9999.0)), + ); + history.push( + now - Duration::from_millis(100), + point(px(100.0), px(100.0)), + ); + let velocity = history.velocity(now); + assert!((f32::from(velocity.x) - 1000.0).abs() < 1.0); + assert!((f32::from(velocity.y) - 1000.0).abs() < 1.0); + assert_eq!(history.entries.len(), 1); + } + + #[test] + fn test_history_clear() { + let mut history = KineticScrollHistory::new(); + let now = Instant::now(); + history.push(now, point(px(100.0), px(100.0))); + assert_eq!(history.entries.len(), 1); + history.clear(); + assert_eq!(history.entries.len(), 0); + } + + #[test] + fn test_touch_phase_sequence() { + let mut scroller = KineticScrollController::new(); + assert!(matches!(scroller.touch_phase(), TouchPhase::Moved)); + + scroller.start_finger_scroll(); + assert!(matches!(scroller.touch_phase(), TouchPhase::Started)); + assert!(matches!(scroller.touch_phase(), TouchPhase::Moved)); + assert!(matches!(scroller.touch_phase(), TouchPhase::Moved)); + } + + #[test] + fn test_start_finger_scroll_id_increments() { + let mut scroller = KineticScrollController::new(); + let id_before = scroller.id; + scroller.start_finger_scroll(); + assert_eq!(scroller.id, id_before + 1); + } + + #[test] + fn test_start_finger_scroll_resets_stop_pending() { + let mut scroller = KineticScrollController::new(); + scroller.start_finger_scroll(); + assert!(scroller.stop_finger_scroll()); + assert!(scroller.has_pending_stop()); + scroller.start_finger_scroll(); + assert!(!scroller.has_pending_stop()); + } + + #[test] + fn test_start_finger_scroll_does_not_reset_active() { + let mut scroller = KineticScrollController::new(); + scroller.start_finger_scroll(); + assert!(scroller.finger_active); + assert!(scroller.finger_start_pending); + + scroller.touch_phase(); + assert!(!scroller.finger_start_pending); + + scroller.start_finger_scroll(); + assert!(scroller.finger_active); + assert!(!scroller.finger_start_pending); + } + + #[test] + fn test_stop_finger_scroll_when_inactive() { + let mut scroller = KineticScrollController::new(); + assert!(!scroller.stop_finger_scroll()); + assert!(!scroller.has_pending_stop()); + } + + #[test] + fn test_stop_finger_scroll_when_active() { + let mut scroller = KineticScrollController::new(); + scroller.start_finger_scroll(); + assert!(scroller.stop_finger_scroll()); + assert!(scroller.has_pending_stop()); + } + + #[test] + fn test_record_delta_and_has_pending_stop() { + let mut scroller = KineticScrollController::new(); + scroller.start_finger_scroll(); + scroller.record_delta(Instant::now(), point(px(10.0), px(20.0))); + assert_eq!(scroller.history.entries.len(), 1); + assert!(!scroller.has_pending_stop()); + + scroller.stop_finger_scroll(); + assert!(scroller.has_pending_stop()); + } +}