Skip to content

Commit ad4dc5c

Browse files
authored
fix(linux): round a scroll to the nearest notch instead of truncating it (#1618)
1 parent f151a66 commit ad4dc5c

7 files changed

Lines changed: 121 additions & 72 deletions

File tree

docs/CROSS_PLATFORM.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,22 @@ event, and the XTEST pointer has no scroll valuator for the smooth XI2 path.
221221
Windows sits with Wayland: `MOUSEEVENTF_WHEEL` counts 120ths of `WHEEL_DELTA`,
222222
so the animator steps in 120ths of a notch.
223223

224-
**So X11 animates in notches, and a scroll worth one notch is not animated at
225-
all.** The default `scroll.scroll_step` of 50 pixels is exactly one notch
226-
there, so a plain `scroll_down` on X11 arrives as the single wheel click it
227-
always did. From two notches up the same eased curve applies as everywhere
228-
else.
224+
**What a `scroll_step` number means differs per platform.** macOS posts
225+
pixel-unit wheel events, so a step of 50 scrolls 50 px. Windows sends
226+
`WHEEL_DELTA` units at 4 per pixel (120 per 30 px notch), so 50 px is 200
227+
units, delivered exactly, and an application accumulates the fraction the
228+
way it does for a high-resolution wheel. Linux converts at 30 px per notch on
229+
each path that sends notches (uinput, the wlroots virtual pointer, XTest) and
230+
rounds to the nearest, never fewer than one. 44 px is one notch, 45 px and
231+
the default 50 px are two, 500 px is seventeen. Rounding rather than
232+
truncation is what keeps a 59 px step from travelling no further than a
233+
30 px one. On KDE, libei carries the pixel delta as is.
234+
235+
**So X11 animates in notches, and a scroll that rounds to one notch is not
236+
animated at all.** A `scroll_step` under 45 pixels on X11 arrives as the
237+
single wheel click it always did. From two notches up, the default included,
238+
the same eased curve applies as everywhere else, and the animated scroll
239+
travels the same notch count as the unanimated one.
229240

230241
Neru sends the same distance on every backend. On Wayland the animated path
231242
spends that distance as a continuous delta where the unanimated one spends it

internal/adapter/accessibility/native/linux/element.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,16 @@ func MouseUp(button action.MouseButton) error {
429429
// different distance depending on which backend answered.
430430
const scrollPixelsPerNotch = 30
431431

432+
// scrollNotches is how many wheel notches a scroll of delta pixels is: the
433+
// nearest whole notch, and never fewer than one so a step shorter than a notch
434+
// still moves. Truncating instead left a default 50 px step one notch short
435+
// of the two it rounds to, and a 59 px step no further than a 30 px one.
436+
// Every unanimated Linux path and the animated total share this, so the two
437+
// travel the same distance.
438+
func scrollNotches(delta int) int {
439+
return max((abs(delta)+scrollPixelsPerNotch/2)/scrollPixelsPerNotch, 1)
440+
}
441+
432442
// ScrollAtCursor scrolls at the cursor, presenting modifiers as held.
433443
//
434444
// Linux has no event-flags concept, so a modifier is a real key press around
@@ -533,9 +543,6 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
533543
waitForWaylandModifierPress()
534544
}
535545

536-
// Scale factor: each uinput scroll event approximates ~1 line.
537-
const scrollScale = scrollPixelsPerNotch
538-
539546
// maxBatchEvents caps the number of uinput events sent per
540547
// write/flush to avoid overflowing the kernel evdev buffer (~8192
541548
// bytes) or the Wayland socket buffer. Each batch is kept small so
@@ -551,10 +558,7 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
551558
return 0
552559
}
553560

554-
totalNotches := abs(delta) / scrollScale
555-
if totalNotches == 0 {
556-
totalNotches = 1
557-
}
561+
totalNotches := scrollNotches(delta)
558562

559563
remainingNotches := totalNotches
560564
batch := make([]int, 0, maxBatchEvents)
@@ -584,7 +588,9 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
584588
}
585589
}
586590

587-
return uinputScrollRemainder(delta, totalNotches, remainingNotches, scrollScale)
591+
return uinputScrollRemainder(
592+
delta, totalNotches, remainingNotches, scrollPixelsPerNotch,
593+
)
588594
}
589595

590596
remainY := sendScaledScroll(uinputScrollAxisVertical, deltaY)
@@ -618,10 +624,12 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
618624
// send, in the caller's pixels, for the virtual pointer to finish.
619625
//
620626
// It is zero whenever every notch went out, even when the delta was not a
621-
// whole number of notches: a 50 px step is one 30 px notch, and the 20 px
622-
// left over is rounding, not a lost scroll. Handing it on used to add a
623-
// second, opposite-signed notch to every press on Hyprland, which Chromium
624-
// and Electron clients summed to nothing.
627+
// whole number of notches: a 50 px step is two 30 px notches, and the 10 px
628+
// left over is rounding, not a lost scroll. Handing it on used to add an
629+
// extra, opposite-signed notch to every press on Hyprland, which Chromium
630+
// and Electron clients summed to nothing. When some notches remain, the
631+
// pixels handed on round to exactly the unsent count, so the virtual pointer
632+
// finishes the same scroll rather than a re-rounded one.
625633
func uinputScrollRemainder(delta, totalNotches, remainingNotches, scale int) int {
626634
if remainingNotches == 0 {
627635
return 0

internal/adapter/accessibility/native/linux/element_wayland_wlroots_cgo.go

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,10 @@ func wlrootsMouseUp(button action.MouseButton) error {
206206
return nil
207207
}
208208

209-
// wlrootsScrollScale mirrors the uinput scroll scaling constant so
210-
// that both backends produce comparable scroll behavior from the same
211-
// pixel-level delta values supplied by the scroll service
212-
// (e.g. ScrollStep=50, ScrollStepHalf=500, ScrollStepFull=1000000).
209+
// wlrootsScrollStep is the pixel value one virtual-pointer notch carries,
210+
// the same figure the uinput path counts notches in, so the two backends
211+
// travel the same distance from the same scroll service delta.
213212
const (
214-
wlrootsScrollScale = scrollPixelsPerNotch
215213
wlrootsScrollMaxEvents = 50
216214
wlrootsScrollStep = scrollPixelsPerNotch // pixels per notch
217215
)
@@ -340,10 +338,7 @@ func (s *waylandScrollSession) close() {
340338
// Application convention: positive delta = scroll up (axis 0) / right (axis 1).
341339
// Vertical axis sign is negated to convert between the two.
342340
func wlrootsScrollAxis(axis int, delta int) error {
343-
totalNotches := abs(delta) / wlrootsScrollScale
344-
if totalNotches == 0 {
345-
totalNotches = 1
346-
}
341+
totalNotches := scrollNotches(delta)
347342

348343
step, disc := wlrootsScrollNotch(axis, delta, wlrootsScrollStep)
349344

internal/adapter/accessibility/native/linux/element_x11_cgo.go

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -230,20 +230,10 @@ func x11ScrollAtCursor(deltaX, deltaY int, modifiers action.Modifiers) error {
230230
// (e.g. ScrollStep=50, ScrollStepHalf=500, ScrollStepFull=1000000).
231231
// We scale them to a capped number of clicks to avoid flooding X11
232232
// with tens of thousands of button events on large scrolls.
233-
const (
234-
scale = scrollPixelsPerNotch
235-
maxClicks = maxScrollUnitsPerRequest
236-
)
233+
const maxClicks = maxScrollUnitsPerRequest
237234

238235
if deltaY != 0 {
239-
yClicks := abs(deltaY) / scale
240-
if yClicks == 0 {
241-
yClicks = 1
242-
}
243-
244-
if yClicks > maxClicks {
245-
yClicks = maxClicks
246-
}
236+
yClicks := min(scrollNotches(deltaY), maxClicks)
247237

248238
for range yClicks {
249239
const mouseButtonVerticalScroll = 4
@@ -261,14 +251,7 @@ func x11ScrollAtCursor(deltaX, deltaY int, modifiers action.Modifiers) error {
261251
}
262252

263253
if deltaX != 0 {
264-
xClicks := abs(deltaX) / scale
265-
if xClicks == 0 {
266-
xClicks = 1
267-
}
268-
269-
if xClicks > maxClicks {
270-
xClicks = maxClicks
271-
}
254+
xClicks := min(scrollNotches(deltaX), maxClicks)
272255

273256
for range xClicks {
274257
const mouseButtonHorizontalScrollRight = 7

internal/adapter/accessibility/native/linux/scroll_animator.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,26 @@ func scrollChunks(delta float64, steps int, granularity float64, maxUnits int) [
155155

156156
total := delta
157157

158-
if granularity > 0 && maxUnits > 0 {
159-
ceiling := float64(maxUnits) * granularity
160-
total = math.Max(math.Min(total, ceiling), -ceiling)
158+
// A granular backend travels the nearest whole number of units, never
159+
// fewer than one, exactly as the unanimated path counts them in
160+
// scrollNotches. The animation spreads the same notches over time, it
161+
// does not shorten the scroll. Rounding up front rather than per step
162+
// is what lets the eased chunks below truncate and still add up to it.
163+
if granularity > 0 {
164+
units := math.Max(math.Round(math.Abs(total)/granularity), 1)
165+
if maxUnits > 0 {
166+
units = math.Min(units, float64(maxUnits))
167+
}
168+
169+
total = math.Copysign(units*granularity, total)
161170
}
162171

163-
// A scroll worth one unit or less on a granular backend is not an
164-
// animation: whatever the curve says, exactly one event goes out. Putting
165-
// it anywhere but the first step would deliver the same single wheel click
166-
// the unanimated path sends, only later — pure added latency, which is the
167-
// one thing this must not buy. X11 with the default scroll_step of 50
168-
// pixels is exactly that case.
169-
if granularity > 0 && math.Trunc(math.Abs(total)/granularity) <= 1 {
172+
// A scroll worth one unit on a granular backend is not an animation:
173+
// whatever the curve says, exactly one event goes out. Putting it anywhere
174+
// but the first step would deliver the same single wheel click the
175+
// unanimated path sends, only later — pure added latency, which is the one
176+
// thing this must not buy. A scroll_step under 45 pixels is that case.
177+
if granularity > 0 && math.Abs(total) == granularity {
170178
unit := granularity
171179
if total < 0 {
172180
unit = -granularity

internal/adapter/accessibility/native/linux/scroll_animator_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,23 +134,23 @@ func TestScrollChunks_KeepsAGranularBackendOnWholeUnits(t *testing.T) {
134134
}
135135
}
136136

137-
// The unanimated X11 path sends abs(delta)/30 clicks, truncated: 16 here.
138-
// Rounding per step would lose several of them.
139-
if got, want := sum(chunks), -16*float64(notch); got != want {
140-
t.Errorf("chunks total %v, want %v (16 notches)", got, want)
137+
// The unanimated X11 path sends scrollNotches(delta) clicks, which is 500
138+
// px rounded to the nearest notch, 17. Rounding per step would lose several.
139+
if got, want := sum(chunks), -17*float64(notch); got != want {
140+
t.Errorf("chunks total %v, want %v (17 notches)", got, want)
141141
}
142142
}
143143

144144
// TestScrollChunks_SendsAOneUnitScrollImmediately covers the case that is not
145-
// an animation at all, and the one the default configuration hits on X11: a
146-
// scroll_step of 50 pixels is a single wheel notch there. One event goes out
147-
// either way, so scheduling it anywhere but the first step would deliver the
148-
// same scroll later — added latency and nothing else.
145+
// an animation at all: a scroll that rounds to a single wheel notch on X11.
146+
// One event goes out either way, so scheduling it anywhere but the first step
147+
// would deliver the same scroll later — added latency and nothing else.
149148
func TestScrollChunks_SendsAOneUnitScrollImmediately(t *testing.T) {
150149
const notch = scrollPixelsPerNotch
151150

152-
// Shorter than a notch, exactly a notch, and the shipped scroll_step.
153-
for _, delta := range []float64{10, -10, 1, -1, notch, -notch, 50, -50} {
151+
// Shorter than a notch, exactly a notch, and the last step that still
152+
// rounds down to one.
153+
for _, delta := range []float64{10, -10, 1, -1, notch, -notch, 44, -44} {
154154
chunks := scrollChunks(delta, 20, notch, maxScrollUnitsPerRequest)
155155

156156
want := float64(notch)
@@ -535,14 +535,15 @@ func TestScrollAnimator_Animate_HeldRepeatOnAGranularBackend(t *testing.T) {
535535
time.Sleep(scrollTestRepeatInterval)
536536
}
537537

538+
// What ten discrete presses send: each rounds to the nearest notch.
538539
var (
539-
asked = float64(ticks * -scrollTestHalfPage)
540+
asked = float64(ticks * scrollNotches(scrollTestHalfPage) * notch)
540541
tolerance = float64(ticks * notch)
541542
)
542543

543544
// The last tick is still draining, so the floor is what has to be waited
544545
// for; the ceiling holds at every instant, since no schedule can send more
545-
// than was asked for.
546+
// notches than the presses would.
546547
waitFor(t, func() bool { return -log.traveled() >= asked-tolerance })
547548

548549
time.Sleep(60 * time.Millisecond)

internal/adapter/accessibility/native/linux/scroll_notch_test.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,51 @@ package linux
44

55
import "testing"
66

7+
// TestScrollNotches_RoundsToTheNearestNotch pins the pixel-to-notch conversion
8+
// every Linux path shares: nearest whole notch, never fewer than one, and the
9+
// animated schedule lands on the same count so switching smooth_scroll on
10+
// changes when a scroll arrives, never how far it goes.
11+
func TestScrollNotches_RoundsToTheNearestNotch(t *testing.T) {
12+
const notch = scrollPixelsPerNotch
13+
14+
tests := []struct {
15+
pixels int
16+
want int
17+
}{
18+
{pixels: 1, want: 1},
19+
{pixels: 29, want: 1},
20+
{pixels: 30, want: 1},
21+
{pixels: 44, want: 1},
22+
{pixels: 45, want: 2},
23+
{pixels: 50, want: 2},
24+
{pixels: 59, want: 2},
25+
{pixels: 60, want: 2},
26+
{pixels: 500, want: 17},
27+
}
28+
29+
for _, testCase := range tests {
30+
for _, delta := range []int{testCase.pixels, -testCase.pixels} {
31+
if got := scrollNotches(delta); got != testCase.want {
32+
t.Errorf("scrollNotches(%d) = %d, want %d", delta, got, testCase.want)
33+
}
34+
35+
want := float64(testCase.want * notch)
36+
if delta < 0 {
37+
want = -want
38+
}
39+
40+
animated := sum(scrollChunks(float64(delta), 20, notch, maxScrollUnitsPerRequest))
41+
if animated != want {
42+
t.Errorf("animated %d px travels %v, want %v (the unanimated %d notches)",
43+
delta, animated, want, testCase.want)
44+
}
45+
}
46+
}
47+
}
48+
749
// TestUinputScrollRemainder_WholeSendLeavesNothing is the Hyprland regression:
8-
// a default 50 px step is one 30 px notch, and the 20 px of rounding left over
9-
// must not go out again on the virtual pointer as a second notch.
50+
// a default 50 px step is two 30 px notches, and the 10 px of rounding left
51+
// over must not go out again on the virtual pointer as a third notch.
1052
func TestUinputScrollRemainder_WholeSendLeavesNothing(t *testing.T) {
1153
const scale = 30
1254

@@ -17,9 +59,10 @@ func TestUinputScrollRemainder_WholeSendLeavesNothing(t *testing.T) {
1759
remaining int
1860
want int
1961
}{
20-
{name: "every notch sent, rounding left over", delta: -50, total: 1, remaining: 0, want: 0},
62+
{name: "every notch sent, rounding left over", delta: -50, total: 2, remaining: 0, want: 0},
63+
{name: "one of two sent hands on the rest", delta: 50, total: 2, remaining: 1, want: 20},
2164
{name: "every notch sent, exact", delta: 60, total: 2, remaining: 0, want: 0},
22-
{name: "nothing sent keeps the whole delta", delta: -50, total: 1, remaining: 1, want: -50},
65+
{name: "nothing sent keeps the whole delta", delta: -50, total: 2, remaining: 2, want: -50},
2366
{name: "half sent keeps the unsent half", delta: 120, total: 4, remaining: 2, want: 60},
2467
}
2568

0 commit comments

Comments
 (0)