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
This commit is contained in:
LIU Xinyu
2026-06-14 21:55:49 -04:00
committed by GitHub
parent 69d467e4d1
commit 676d5897ae
3 changed files with 522 additions and 2 deletions
+1
View File
@@ -2,6 +2,7 @@ mod client;
mod clipboard;
mod cursor;
mod display;
mod scroll;
mod serial;
mod window;
+93 -2
View File
@@ -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<wl_pointer::WlPointer>,
pinch_gesture: Option<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1>,
hold_gesture: Option<zwp_pointer_gesture_hold_v1::ZwpPointerGestureHoldV1>,
pinch_scale: f32,
wl_keyboard: Option<wl_keyboard::WlKeyboard>,
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
@@ -248,6 +250,7 @@ pub(crate) struct WaylandClientState {
pub mouse_location: Option<Point<Pixels>>,
continuous_scroll_delta: Option<Point<Pixels>>,
discrete_scroll_delta: Option<Point<f32>>,
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<WlCallback, ObjectId> 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<wl_seat::WlSeat, ()> 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<wl_pointer::WlPointer, ()> 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<wl_pointer::WlPointer, ()> 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<wl_pointer::WlPointer, ()> 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<wl_pointer::WlPointer, ()> 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<wl_pointer::WlPointer, ()> 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<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, ()>
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<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, ()>
}
}
impl Dispatch<zwp_pointer_gesture_hold_v1::ZwpPointerGestureHoldV1, ()> for WaylandClientStatePtr {
fn event(
this: &mut Self,
_: &zwp_pointer_gesture_hold_v1::ZwpPointerGestureHoldV1,
event: <zwp_pointer_gesture_hold_v1::ZwpPointerGestureHoldV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<wp_fractional_scale_v1::WpFractionalScaleV1, ObjectId> for WaylandClientStatePtr {
fn event(
this: &mut Self,
@@ -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<KineticScroll>,
finger_active: bool,
finger_start_pending: bool,
finger_stop_pending: bool,
}
struct KineticScrollHistory {
entries: VecDeque<(Instant, Point<Pixels>)>,
displacement: Point<Pixels>,
}
struct KineticScroll {
id: u64,
window: WaylandWindowStatePtr,
position: Point<Pixels>,
modifiers: Modifiers,
velocity: Point<Pixels>,
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<Pixels>) {
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<Pixels>,
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<Pixels>,
modifiers: Modifiers,
velocity: Point<Pixels>,
) -> 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<Pixels>) {
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<Pixels> {
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<Pixels>) -> 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());
}
}