|
| 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 | +} |
0 commit comments