Skip to content

Commit e1199bc

Browse files
committed
Initial implementation of momentum for finger based scrolling on Wayland
Needs configuration and possibly the parameter adjustment once pixel scrolling is merged.
1 parent cd25248 commit e1199bc

File tree

6 files changed

+326
-7
lines changed

6 files changed

+326
-7
lines changed

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ Detailed list of changes
153153
0.45.1 [future]
154154
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
155155

156+
- Wayland: Add momentum scrolling for touchpads
157+
156158
- choose-files kitten: Fix JXL image preview not working (:iss:`9323`)
157159

158160
- Fix tab bar rendering glitches when using :opt:`tab_bar_filter` in some

glfw/internal.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,7 @@ void _glfwPlatformRemoveTimer(unsigned long long timer_id);
885885
int _glfwPlatformSetWindowBlur(_GLFWwindow* handle, int value);
886886
MonitorGeometry _glfwPlatformGetMonitorGeometry(_GLFWmonitor* monitor);
887887
bool _glfwPlatformGrabKeyboard(bool grab);
888+
void glfw_handle_scroll_event_for_momentum(_GLFWwindow *w, const GLFWScrollEvent *ev, monotonic_t timestamp, bool stopped, bool is_finger_based);
888889

889890
char* _glfw_strdup(const char* source);
890891

glfw/momentum-scroll.c

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* momentum-scroll.c
3+
* Copyright (C) 2026 Kovid Goyal <kovid at kovidgoyal.net>
4+
*
5+
* Distributed under terms of the GPL3 license.
6+
*/
7+
8+
#include "internal.h"
9+
#include <math.h>
10+
11+
typedef struct ScrollSample {
12+
double dx, dy;
13+
monotonic_t timestamp, local_timestamp;
14+
} ScrollSample;
15+
16+
#define DEQUE_DATA_TYPE ScrollSample
17+
#define DEQUE_NAME ScrollSamples
18+
#include "../kitty/fixed_size_deque.h"
19+
20+
typedef enum ScrollerState { NONE, PHYSICAL_EVENT_IN_PROGRESS, MOMENTUM_IN_PROGRESS } ScrollerState;
21+
22+
typedef struct MomentumScroller {
23+
double friction, // Deceleration factor (0-1, lower = longer coast)
24+
min_velocity, // Minimum velocity before stopping
25+
max_velocity, // Maximum velocity to prevent runaway scrolling
26+
boost_factor, // How much to speed up scrolling
27+
velocity_scale; // Scale factor for initial velocity
28+
unsigned timer_interval_ms;
29+
30+
GLFWid timer_id, window_id;
31+
ScrollSamples samples;
32+
ScrollerState state;
33+
struct { double x, y; } velocity;
34+
int keyboard_modifiers;
35+
} MomentumScroller;
36+
37+
static MomentumScroller s = {
38+
.friction = 0.04,
39+
.min_velocity = 0.5,
40+
.max_velocity = 100,
41+
.boost_factor = 1.2,
42+
.velocity_scale = 0.9,
43+
.timer_interval_ms = 10,
44+
};
45+
46+
static void
47+
cancel_existing_scroll(void) {
48+
if (s.timer_id) {
49+
glfwRemoveTimer(s.timer_id);
50+
s.timer_id = 0;
51+
}
52+
if (s.state == MOMENTUM_IN_PROGRESS) {
53+
_GLFWwindow *w = _glfwWindowForId(s.window_id);
54+
if (w) _glfwInputScroll(
55+
w, &(GLFWScrollEvent){.momentum_type=GLFW_MOMENTUM_PHASE_CANCELED, .keyboard_modifiers=s.keyboard_modifiers});
56+
}
57+
s.window_id = 0;
58+
s.keyboard_modifiers = 0;
59+
deque_clear(&s.samples);
60+
s.state = NONE;
61+
}
62+
63+
static void
64+
add_sample(double dx, double dy, monotonic_t timestamp) {
65+
deque_push_back(&s.samples, (ScrollSample){dx, dy, timestamp, monotonic()}, NULL);
66+
}
67+
68+
static void
69+
last_sample_delta(double *dx, double *dy) {
70+
const ScrollSample *ss;
71+
if ((ss = deque_peek_back(&s.samples))) { *dx = ss->dx; *dy = ss->dy; }
72+
else { *dx = 0; *dy = 0; }
73+
}
74+
75+
static void
76+
trim_old_samples(monotonic_t now) {
77+
const ScrollSample *ss;
78+
while ((ss = deque_peek_front(&s.samples)) && (now - ss->local_timestamp) > ms_to_monotonic_t(150))
79+
deque_pop_front(&s.samples, NULL);
80+
}
81+
82+
static void
83+
add_velocity(double x, double y) {
84+
if (x == 0 || x * s.velocity.x >= 0) s.velocity.x += x;
85+
else s.velocity.x = x;
86+
if (y == 0 || y * s.velocity.y >= 0) s.velocity.y += y;
87+
else s.velocity.y = y;
88+
s.velocity.x = MAX(-s.max_velocity, MIN(s.velocity.x, s.max_velocity));
89+
s.velocity.y = MAX(-s.max_velocity, MIN(s.velocity.y, s.max_velocity));
90+
}
91+
92+
static void
93+
set_velocity_from_samples(void) {
94+
trim_old_samples(monotonic());
95+
ScrollSample ss;
96+
switch (deque_size(&s.samples)) {
97+
case 0:
98+
return;
99+
case 1:
100+
deque_pop_front(&s.samples, &ss);
101+
add_velocity(s.velocity_scale * ss.dx, s.velocity_scale * ss.dy);
102+
return;
103+
}
104+
105+
// Use weighted average - more recent samples have higher weight
106+
double total_dx = 0.0, total_dy = 0.0, total_weight = 0.0;
107+
monotonic_t first_time = deque_peek_front(&s.samples)->local_timestamp;
108+
monotonic_t last_time = deque_peek_back(&s.samples)->local_timestamp;
109+
double time_span = MAX(1, last_time - first_time);
110+
for (size_t i = 0; i < deque_size(&s.samples); i++) {
111+
const ScrollSample *ss = deque_at(&s.samples, i);
112+
double weight = 1.0 + (ss->local_timestamp - first_time) / time_span;
113+
total_dx += ss->dx * weight; total_dy += ss->dy * weight;
114+
total_weight += weight;
115+
}
116+
deque_clear(&s.samples);
117+
if (total_weight <= 0) return;
118+
add_velocity((total_dx / total_weight) * s.velocity_scale, (total_dy / total_weight) * s.velocity_scale);
119+
}
120+
121+
static void
122+
send_momentum_event(bool is_start) {
123+
double friction = 1.0 - MAX(0, MIN(s.friction, 1.));
124+
s.velocity.x *= friction; s.velocity.y *= friction;
125+
if (fabs(s.velocity.x) < s.min_velocity) s.velocity.x = 0;
126+
if (fabs(s.velocity.y) < s.min_velocity) s.velocity.y = 0;
127+
_GLFWwindow *w = _glfwWindowForId(s.window_id);
128+
if (!w || w != _glfwFocusedWindow()) {
129+
cancel_existing_scroll();
130+
return;
131+
}
132+
GLFWMomentumType m = is_start ? GLFW_MOMENTUM_PHASE_BEGAN : GLFW_MOMENTUM_PHASE_ACTIVE;
133+
if (s.velocity.x == 0 && s.velocity.y == 0 && !is_start) {
134+
m = GLFW_MOMENTUM_PHASE_ENDED;
135+
if (s.timer_id) glfwRemoveTimer(s.timer_id);
136+
s.timer_id = 0;
137+
}
138+
GLFWScrollEvent e = {
139+
.offset_type=GLFW_SCROLL_OFFEST_HIGHRES, .momentum_type=m, .x_offset=s.velocity.x, .y_offset=s.velocity.y,
140+
.keyboard_modifiers=s.keyboard_modifiers
141+
};
142+
_glfwInputScroll(w, &e);
143+
}
144+
145+
static void
146+
momentum_timer_fired(unsigned long long timer_id UNUSED, void *data UNUSED) {
147+
send_momentum_event(false);
148+
}
149+
150+
static void
151+
start_momentum_scroll(void) {
152+
set_velocity_from_samples();
153+
send_momentum_event(true);
154+
s.timer_id = glfwAddTimer(ms_to_monotonic_t(s.timer_interval_ms), true, momentum_timer_fired, NULL, NULL);
155+
}
156+
157+
void
158+
glfw_handle_scroll_event_for_momentum(
159+
_GLFWwindow *w, const GLFWScrollEvent *ev, monotonic_t timestamp, bool stopped, bool is_finger_based
160+
) {
161+
if (!w || (w->id != s.window_id && s.window_id) || s.state != PHYSICAL_EVENT_IN_PROGRESS) cancel_existing_scroll();
162+
if (!w) return;
163+
// Check for change in direction
164+
double ldx, ldy; last_sample_delta(&ldx, &ldy);
165+
if (ldx * ev->x_offset < 0 || ldy * ev->y_offset < 0) {
166+
s.velocity.x = 0; s.velocity.y = 0;
167+
cancel_existing_scroll();
168+
}
169+
s.window_id = w->id;
170+
s.keyboard_modifiers = ev->keyboard_modifiers;
171+
if (is_finger_based) {
172+
add_sample(ev->x_offset, ev->y_offset, timestamp);
173+
s.state = stopped ? MOMENTUM_IN_PROGRESS : PHYSICAL_EVENT_IN_PROGRESS;
174+
} else {
175+
s.state = stopped ? NONE : PHYSICAL_EVENT_IN_PROGRESS;
176+
}
177+
if (s.state == MOMENTUM_IN_PROGRESS) start_momentum_scroll();
178+
else _glfwInputScroll(w, ev);
179+
}

glfw/source-info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"linux_joystick.c",
111111
"linux_desktop_settings.c",
112112
"null_joystick.c",
113+
"momentum-scroll.c",
113114
"linux_notify.c"
114115
]
115116
},

glfw/wl_init.c

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,14 @@ pointer_handle_frame(void *data UNUSED, struct wl_pointer *pointer UNUSED) {
221221
ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES;
222222
ev.x_offset = info.continuous.x;
223223
}
224+
float scale = (float)_glfwWaylandWindowScale(window);
225+
ev.x_offset *= scale; ev.y_offset *= scale;
226+
ev.x_offset *= -1;
227+
glfw_handle_scroll_event_for_momentum(
228+
window, &ev, MAX(info.y_start_time, info.x_start_time), info.y_stop_received || info.x_stop_received,
229+
info.source_type == WL_POINTER_AXIS_SOURCE_FINGER);
224230
/* clear pointer_curr_axis_info for next frame */
225231
memset(&info, 0, sizeof(info));
226-
227-
if (ev.y_offset != 0.0f || ev.x_offset != 0.0f) {
228-
float scale = (float)_glfwWaylandWindowScale(window);
229-
ev.x_offset *= scale; ev.y_offset *= scale;
230-
ev.x_offset *= -1;
231-
_glfwInputScroll(window, &ev);
232-
}
233232
}
234233

235234
static void

kitty/fixed_size_deque.h

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* fixed_size_deque.h
3+
* Copyright (C) 2026 Kovid Goyal <kovid at kovidgoyal.net>
4+
*
5+
* Distributed under terms of the GPL3 license.
6+
*
7+
* A fixed size deque that does not allocate. To use define DEQUE_NAME, DEQUE_CAPACITY and
8+
* DEQUE_DATA_TYPE and include header. Use deque_push_back() to append. To
9+
* iterate in append order use deque_at(i) for 0 <= i < deque_size().
10+
*/
11+
12+
#include <stdbool.h>
13+
#include <string.h>
14+
15+
#ifndef DEQUE_NAME
16+
#define DEQUE_NAME CircularDeque
17+
#endif
18+
19+
#ifndef DEQUE_CAPACITY
20+
#define DEQUE_CAPACITY 50
21+
#endif
22+
23+
#ifndef DEQUE_DATA_TYPE
24+
#define DEQUE_DATA_TYPE int
25+
#endif
26+
27+
typedef struct {
28+
DEQUE_DATA_TYPE items[DEQUE_CAPACITY];
29+
unsigned head; // Index of first element
30+
unsigned tail; // Index one past last element
31+
unsigned count; // Number of elements
32+
} DEQUE_NAME;
33+
34+
// Check if empty
35+
static inline bool
36+
deque_is_empty(const DEQUE_NAME* dq) { return dq->count == 0; }
37+
38+
// Check if full
39+
static inline bool
40+
deque_is_full(const DEQUE_NAME* dq) { return dq->count == DEQUE_CAPACITY; }
41+
42+
// Get current size
43+
static inline unsigned
44+
deque_size(const DEQUE_NAME* dq) { return dq->count; }
45+
46+
// Push to back auto-evicts from front if full.
47+
// Returns true if an item was evicted, which will be copied to *evicted_item is not NULL.
48+
static inline bool
49+
deque_push_back(DEQUE_NAME* dq, DEQUE_DATA_TYPE item, DEQUE_DATA_TYPE *evicted_item) {
50+
bool evicted = false;
51+
if (deque_is_full(dq)) {
52+
// Evict front item
53+
if (evicted_item) *evicted_item = dq->items[dq->head];
54+
evicted = true;
55+
dq->head = (dq->head + 1) % DEQUE_CAPACITY;
56+
dq->count--;
57+
}
58+
dq->items[dq->tail] = item;
59+
dq->tail = (dq->tail + 1) % DEQUE_CAPACITY;
60+
dq->count++;
61+
return evicted;
62+
}
63+
64+
// Push to front, auto-evicts from back if full.
65+
// Returns true if an item was evicted, which will be copied to *evicted_item is not NULL.
66+
static inline bool
67+
deque_push_front(DEQUE_NAME* dq, DEQUE_DATA_TYPE item, DEQUE_DATA_TYPE *evicted_item) {
68+
bool evicted = false;
69+
70+
if (deque_is_full(dq)) {
71+
// Evict oldest (back) item
72+
dq->tail = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY;
73+
if (evicted_item) *evicted_item = dq->items[dq->tail];
74+
evicted = true;
75+
dq->count--;
76+
}
77+
78+
dq->head = (dq->head - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY;
79+
dq->items[dq->head] = item;
80+
dq->count++;
81+
82+
return evicted;
83+
}
84+
85+
// Pop from front
86+
static inline bool
87+
deque_pop_front(DEQUE_NAME* dq, DEQUE_DATA_TYPE *ans) {
88+
if (deque_is_empty(dq)) return false;
89+
if (ans) *ans = dq->items[dq->head];
90+
dq->head = (dq->head + 1) % DEQUE_CAPACITY;
91+
dq->count--;
92+
return true;
93+
}
94+
95+
// Pop from back
96+
static inline bool
97+
deque_pop_back(DEQUE_NAME* dq, DEQUE_DATA_TYPE *ans) {
98+
if (deque_is_empty(dq)) return false;
99+
dq->tail = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY;
100+
if (ans) *ans = dq->items[dq->tail];
101+
dq->count--;
102+
return true;
103+
}
104+
105+
// Peek at front without removing
106+
static inline const DEQUE_DATA_TYPE*
107+
deque_peek_front(const DEQUE_NAME* dq) {
108+
if (deque_is_empty(dq)) return NULL;
109+
return &dq->items[dq->head];
110+
}
111+
112+
// Peek at back without removing
113+
static inline const DEQUE_DATA_TYPE*
114+
deque_peek_back(const DEQUE_NAME* dq) {
115+
if (deque_is_empty(dq)) return NULL;
116+
int idx = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY;
117+
return &dq->items[idx];
118+
}
119+
120+
// Access by index (0 = oldest, count-1 = newest)
121+
static inline const DEQUE_DATA_TYPE*
122+
deque_at(const DEQUE_NAME* dq, unsigned index) {
123+
if (index >= dq->count) return NULL;
124+
return &dq->items[(dq->head + index) % DEQUE_CAPACITY];
125+
}
126+
127+
// Clear all items (doesn't free items)
128+
static inline void
129+
deque_clear(DEQUE_NAME* dq) {
130+
dq->head = 0;
131+
dq->tail = 0;
132+
dq->count = 0;
133+
}
134+
135+
#undef DEQUE_CAPACITY
136+
#undef DEQUE_DATA_TYPE
137+
#undef DEQUE_NAME

0 commit comments

Comments
 (0)