diff --git a/docs/changelog.rst b/docs/changelog.rst index 9eccca5f4..a2cef45d2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -153,6 +153,8 @@ Detailed list of changes 0.45.1 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Wayland: Add momentum scrolling for touchpads + - choose-files kitten: Fix JXL image preview not working (:iss:`9323`) - Fix tab bar rendering glitches when using :opt:`tab_bar_filter` in some diff --git a/glfw/internal.h b/glfw/internal.h index b514386ad..1c32d1299 100644 --- a/glfw/internal.h +++ b/glfw/internal.h @@ -885,6 +885,7 @@ void _glfwPlatformRemoveTimer(unsigned long long timer_id); int _glfwPlatformSetWindowBlur(_GLFWwindow* handle, int value); MonitorGeometry _glfwPlatformGetMonitorGeometry(_GLFWmonitor* monitor); bool _glfwPlatformGrabKeyboard(bool grab); +void glfw_handle_scroll_event_for_momentum(_GLFWwindow *w, const GLFWScrollEvent *ev, monotonic_t timestamp, bool stopped, bool is_finger_based); char* _glfw_strdup(const char* source); diff --git a/glfw/momentum-scroll.c b/glfw/momentum-scroll.c new file mode 100644 index 000000000..c77d7444f --- /dev/null +++ b/glfw/momentum-scroll.c @@ -0,0 +1,179 @@ +/* + * momentum-scroll.c + * Copyright (C) 2026 Kovid Goyal + * + * Distributed under terms of the GPL3 license. + */ + +#include "internal.h" +#include + +typedef struct ScrollSample { + double dx, dy; + monotonic_t timestamp, local_timestamp; +} ScrollSample; + +#define DEQUE_DATA_TYPE ScrollSample +#define DEQUE_NAME ScrollSamples +#include "../kitty/fixed_size_deque.h" + +typedef enum ScrollerState { NONE, PHYSICAL_EVENT_IN_PROGRESS, MOMENTUM_IN_PROGRESS } ScrollerState; + +typedef struct MomentumScroller { + double friction, // Deceleration factor (0-1, lower = longer coast) + min_velocity, // Minimum velocity before stopping + max_velocity, // Maximum velocity to prevent runaway scrolling + boost_factor, // How much to speed up scrolling + velocity_scale; // Scale factor for initial velocity + unsigned timer_interval_ms; + + GLFWid timer_id, window_id; + ScrollSamples samples; + ScrollerState state; + struct { double x, y; } velocity; + int keyboard_modifiers; +} MomentumScroller; + +static MomentumScroller s = { + .friction = 0.04, + .min_velocity = 0.5, + .max_velocity = 100, + .boost_factor = 1.2, + .velocity_scale = 0.9, + .timer_interval_ms = 10, +}; + +static void +cancel_existing_scroll(void) { + if (s.timer_id) { + glfwRemoveTimer(s.timer_id); + s.timer_id = 0; + } + if (s.state == MOMENTUM_IN_PROGRESS) { + _GLFWwindow *w = _glfwWindowForId(s.window_id); + if (w) _glfwInputScroll( + w, &(GLFWScrollEvent){.momentum_type=GLFW_MOMENTUM_PHASE_CANCELED, .keyboard_modifiers=s.keyboard_modifiers}); + } + s.window_id = 0; + s.keyboard_modifiers = 0; + deque_clear(&s.samples); + s.state = NONE; +} + +static void +add_sample(double dx, double dy, monotonic_t timestamp) { + deque_push_back(&s.samples, (ScrollSample){dx, dy, timestamp, monotonic()}, NULL); +} + +static void +last_sample_delta(double *dx, double *dy) { + const ScrollSample *ss; + if ((ss = deque_peek_back(&s.samples))) { *dx = ss->dx; *dy = ss->dy; } + else { *dx = 0; *dy = 0; } +} + +static void +trim_old_samples(monotonic_t now) { + const ScrollSample *ss; + while ((ss = deque_peek_front(&s.samples)) && (now - ss->local_timestamp) > ms_to_monotonic_t(150)) + deque_pop_front(&s.samples, NULL); +} + +static void +add_velocity(double x, double y) { + if (x == 0 || x * s.velocity.x >= 0) s.velocity.x += x; + else s.velocity.x = x; + if (y == 0 || y * s.velocity.y >= 0) s.velocity.y += y; + else s.velocity.y = y; + s.velocity.x = MAX(-s.max_velocity, MIN(s.velocity.x, s.max_velocity)); + s.velocity.y = MAX(-s.max_velocity, MIN(s.velocity.y, s.max_velocity)); +} + +static void +set_velocity_from_samples(void) { + trim_old_samples(monotonic()); + ScrollSample ss; + switch (deque_size(&s.samples)) { + case 0: + return; + case 1: + deque_pop_front(&s.samples, &ss); + add_velocity(s.velocity_scale * ss.dx, s.velocity_scale * ss.dy); + return; + } + + // Use weighted average - more recent samples have higher weight + double total_dx = 0.0, total_dy = 0.0, total_weight = 0.0; + monotonic_t first_time = deque_peek_front(&s.samples)->local_timestamp; + monotonic_t last_time = deque_peek_back(&s.samples)->local_timestamp; + double time_span = MAX(1, last_time - first_time); + for (size_t i = 0; i < deque_size(&s.samples); i++) { + const ScrollSample *ss = deque_at(&s.samples, i); + double weight = 1.0 + (ss->local_timestamp - first_time) / time_span; + total_dx += ss->dx * weight; total_dy += ss->dy * weight; + total_weight += weight; + } + deque_clear(&s.samples); + if (total_weight <= 0) return; + add_velocity((total_dx / total_weight) * s.velocity_scale, (total_dy / total_weight) * s.velocity_scale); +} + +static void +send_momentum_event(bool is_start) { + double friction = 1.0 - MAX(0, MIN(s.friction, 1.)); + s.velocity.x *= friction; s.velocity.y *= friction; + if (fabs(s.velocity.x) < s.min_velocity) s.velocity.x = 0; + if (fabs(s.velocity.y) < s.min_velocity) s.velocity.y = 0; + _GLFWwindow *w = _glfwWindowForId(s.window_id); + if (!w || w != _glfwFocusedWindow()) { + cancel_existing_scroll(); + return; + } + GLFWMomentumType m = is_start ? GLFW_MOMENTUM_PHASE_BEGAN : GLFW_MOMENTUM_PHASE_ACTIVE; + if (s.velocity.x == 0 && s.velocity.y == 0 && !is_start) { + m = GLFW_MOMENTUM_PHASE_ENDED; + if (s.timer_id) glfwRemoveTimer(s.timer_id); + s.timer_id = 0; + } + GLFWScrollEvent e = { + .offset_type=GLFW_SCROLL_OFFEST_HIGHRES, .momentum_type=m, .x_offset=s.velocity.x, .y_offset=s.velocity.y, + .keyboard_modifiers=s.keyboard_modifiers + }; + _glfwInputScroll(w, &e); +} + +static void +momentum_timer_fired(unsigned long long timer_id UNUSED, void *data UNUSED) { + send_momentum_event(false); +} + +static void +start_momentum_scroll(void) { + set_velocity_from_samples(); + send_momentum_event(true); + s.timer_id = glfwAddTimer(ms_to_monotonic_t(s.timer_interval_ms), true, momentum_timer_fired, NULL, NULL); +} + +void +glfw_handle_scroll_event_for_momentum( + _GLFWwindow *w, const GLFWScrollEvent *ev, monotonic_t timestamp, bool stopped, bool is_finger_based +) { + if (!w || (w->id != s.window_id && s.window_id) || s.state != PHYSICAL_EVENT_IN_PROGRESS) cancel_existing_scroll(); + if (!w) return; + // Check for change in direction + double ldx, ldy; last_sample_delta(&ldx, &ldy); + if (ldx * ev->x_offset < 0 || ldy * ev->y_offset < 0) { + s.velocity.x = 0; s.velocity.y = 0; + cancel_existing_scroll(); + } + s.window_id = w->id; + s.keyboard_modifiers = ev->keyboard_modifiers; + if (is_finger_based) { + add_sample(ev->x_offset, ev->y_offset, timestamp); + s.state = stopped ? MOMENTUM_IN_PROGRESS : PHYSICAL_EVENT_IN_PROGRESS; + } else { + s.state = stopped ? NONE : PHYSICAL_EVENT_IN_PROGRESS; + } + if (s.state == MOMENTUM_IN_PROGRESS) start_momentum_scroll(); + else _glfwInputScroll(w, ev); +} diff --git a/glfw/source-info.json b/glfw/source-info.json index cfa47a651..c74fa5901 100644 --- a/glfw/source-info.json +++ b/glfw/source-info.json @@ -110,6 +110,7 @@ "linux_joystick.c", "linux_desktop_settings.c", "null_joystick.c", + "momentum-scroll.c", "linux_notify.c" ] }, diff --git a/glfw/wl_init.c b/glfw/wl_init.c index dfcaab686..723597973 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -221,15 +221,14 @@ pointer_handle_frame(void *data UNUSED, struct wl_pointer *pointer UNUSED) { ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES; ev.x_offset = info.continuous.x; } + float scale = (float)_glfwWaylandWindowScale(window); + ev.x_offset *= scale; ev.y_offset *= scale; + ev.x_offset *= -1; + glfw_handle_scroll_event_for_momentum( + window, &ev, MAX(info.y_start_time, info.x_start_time), info.y_stop_received || info.x_stop_received, + info.source_type == WL_POINTER_AXIS_SOURCE_FINGER); /* clear pointer_curr_axis_info for next frame */ memset(&info, 0, sizeof(info)); - - if (ev.y_offset != 0.0f || ev.x_offset != 0.0f) { - float scale = (float)_glfwWaylandWindowScale(window); - ev.x_offset *= scale; ev.y_offset *= scale; - ev.x_offset *= -1; - _glfwInputScroll(window, &ev); - } } static void diff --git a/kitty/fixed_size_deque.h b/kitty/fixed_size_deque.h new file mode 100644 index 000000000..fe96e14f4 --- /dev/null +++ b/kitty/fixed_size_deque.h @@ -0,0 +1,137 @@ +/* + * fixed_size_deque.h + * Copyright (C) 2026 Kovid Goyal + * + * Distributed under terms of the GPL3 license. + * + * A fixed size deque that does not allocate. To use define DEQUE_NAME, DEQUE_CAPACITY and + * DEQUE_DATA_TYPE and include header. Use deque_push_back() to append. To + * iterate in append order use deque_at(i) for 0 <= i < deque_size(). + */ + +#include +#include + +#ifndef DEQUE_NAME +#define DEQUE_NAME CircularDeque +#endif + +#ifndef DEQUE_CAPACITY +#define DEQUE_CAPACITY 50 +#endif + +#ifndef DEQUE_DATA_TYPE +#define DEQUE_DATA_TYPE int +#endif + +typedef struct { + DEQUE_DATA_TYPE items[DEQUE_CAPACITY]; + unsigned head; // Index of first element + unsigned tail; // Index one past last element + unsigned count; // Number of elements +} DEQUE_NAME; + +// Check if empty +static inline bool +deque_is_empty(const DEQUE_NAME* dq) { return dq->count == 0; } + +// Check if full +static inline bool +deque_is_full(const DEQUE_NAME* dq) { return dq->count == DEQUE_CAPACITY; } + +// Get current size +static inline unsigned +deque_size(const DEQUE_NAME* dq) { return dq->count; } + +// Push to back auto-evicts from front if full. +// Returns true if an item was evicted, which will be copied to *evicted_item is not NULL. +static inline bool +deque_push_back(DEQUE_NAME* dq, DEQUE_DATA_TYPE item, DEQUE_DATA_TYPE *evicted_item) { + bool evicted = false; + if (deque_is_full(dq)) { + // Evict front item + if (evicted_item) *evicted_item = dq->items[dq->head]; + evicted = true; + dq->head = (dq->head + 1) % DEQUE_CAPACITY; + dq->count--; + } + dq->items[dq->tail] = item; + dq->tail = (dq->tail + 1) % DEQUE_CAPACITY; + dq->count++; + return evicted; +} + +// Push to front, auto-evicts from back if full. +// Returns true if an item was evicted, which will be copied to *evicted_item is not NULL. +static inline bool +deque_push_front(DEQUE_NAME* dq, DEQUE_DATA_TYPE item, DEQUE_DATA_TYPE *evicted_item) { + bool evicted = false; + + if (deque_is_full(dq)) { + // Evict oldest (back) item + dq->tail = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY; + if (evicted_item) *evicted_item = dq->items[dq->tail]; + evicted = true; + dq->count--; + } + + dq->head = (dq->head - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY; + dq->items[dq->head] = item; + dq->count++; + + return evicted; +} + +// Pop from front +static inline bool +deque_pop_front(DEQUE_NAME* dq, DEQUE_DATA_TYPE *ans) { + if (deque_is_empty(dq)) return false; + if (ans) *ans = dq->items[dq->head]; + dq->head = (dq->head + 1) % DEQUE_CAPACITY; + dq->count--; + return true; +} + +// Pop from back +static inline bool +deque_pop_back(DEQUE_NAME* dq, DEQUE_DATA_TYPE *ans) { + if (deque_is_empty(dq)) return false; + dq->tail = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY; + if (ans) *ans = dq->items[dq->tail]; + dq->count--; + return true; +} + +// Peek at front without removing +static inline const DEQUE_DATA_TYPE* +deque_peek_front(const DEQUE_NAME* dq) { + if (deque_is_empty(dq)) return NULL; + return &dq->items[dq->head]; +} + +// Peek at back without removing +static inline const DEQUE_DATA_TYPE* +deque_peek_back(const DEQUE_NAME* dq) { + if (deque_is_empty(dq)) return NULL; + int idx = (dq->tail - 1 + DEQUE_CAPACITY) % DEQUE_CAPACITY; + return &dq->items[idx]; +} + +// Access by index (0 = oldest, count-1 = newest) +static inline const DEQUE_DATA_TYPE* +deque_at(const DEQUE_NAME* dq, unsigned index) { + if (index >= dq->count) return NULL; + return &dq->items[(dq->head + index) % DEQUE_CAPACITY]; +} + +// Clear all items (doesn't free items) +static inline void +deque_clear(DEQUE_NAME* dq) { + dq->head = 0; + dq->tail = 0; + dq->count = 0; +} + +#undef DEQUE_CAPACITY +#undef DEQUE_DATA_TYPE +#undef DEQUE_NAME