Skip to content

Commit eb6d00a

Browse files
authored
feat(linux): animate smooth scroll on every backend (#1473)
1 parent ad4be0b commit eb6d00a

31 files changed

Lines changed: 2392 additions & 76 deletions

docs/CONFIGURATION.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1530,7 +1530,16 @@ speeds up over the ramp exactly as without animation — just smoothly.
15301530

15311531
Splits scroll deltas into chunked ease-out events for visual feedback.
15321532

1533-
**Platforms:** macOS only. Linux and Windows fall back to instant scrolling.
1533+
**Platforms:** macOS and Linux. Windows falls back to instant scrolling, and on
1534+
Linux X11 a step can be no finer than a wheel notch — what that means for a
1535+
given scroll is in the
1536+
[capability matrix](CROSS_PLATFORM.md#capability-matrix).
1537+
1538+
Neru sends the same distance whether the animation is on or off. On Wayland it
1539+
sends it in a different *currency*: a continuous delta rather than a count of
1540+
wheel notches, and an application is free to scale those two differently. So
1541+
turning the animation on can change how far a `scroll_down` reaches in a given
1542+
application, and `scroll.scroll_step` is the setting to trim if it does.
15341543

15351544
| Option | Type | Default | Description |
15361545
| -------------------- | ----- | ------- | ---------------------------------- |

docs/CROSS_PLATFORM.md

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ that is what [Known Gaps](#known-gaps) tracks, per
148148
| **Scroll injection** | ✅ both axes | ✅ both axes | ✅ both axes (uinput + virtual pointer) | ✅ libei | ⚠️ vertical only |
149149
| **Modified scroll (`--modifier`)** |`CGEventSetFlags` on every chunk | ✅ XTest key hold | ✅ virtual keyboard, uinput batch skipped | ✅ libei |`SendInput` key hold |
150150
| **Smooth cursor animation** | ✅ (incl. relative, opt-in) | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in ||
151-
| **Smooth scroll animation** || ||| |
151+
| **Smooth scroll animation** || ⚠️ whole notches only ⁴ | ✅ continuous virtual-pointer axis ⁴ | ⚠️ libei scroll delta, unverified ⁴ ||
152152
| **Element discovery (hints)** | ✅ AXUIElement | ⚠️ AT-SPI walk | ⚠️ AT-SPI walk | ⚠️ AT-SPI walk | ⚠️ UIA, shallow tree |
153153
| **Overlay** | ✅ NSPanel + CoreAnimation | ✅ X11 + Cairo | ✅ layer-shell + Cairo | ✅ layer-shell + Cairo | ✅ layered HWND + GDI |
154154
| **Global hotkeys** | ✅ per-key CGEventTap |`XGrabKey` | ⚠️ passive evdev read | ⚠️ passive evdev read |`RegisterHotKey` |
@@ -219,6 +219,41 @@ Linux backend alike. A machine booted by runit, OpenRC or s6 gets
219219
`CodeNotSupported` from every `neru services` subcommand — a stated boundary
220220
rather than a gap, see "Service management on Linux" below.
221221

222+
`smooth_scroll` animates on every Linux backend, but only Wayland can make a
223+
step shorter than a wheel notch. `zwlr_virtual_pointer_v1.axis` carries a
224+
fractional value with no discrete step count, and wlroots forwards exactly that
225+
to the focused client as a continuous `wl_pointer.axis`; libei's
226+
`ei_device_scroll_delta` is pixel-precise and KWin forwards it the same way.
227+
X11 has no such value to send: core scrolling is buttons 4 to 7 and a button
228+
event is one notch by definition, and the XTEST pointer the server creates for
229+
`XTestFakeButtonEvent` is allocated with two axes, `Rel X` and `Rel Y`
230+
(`CorePointerProc` in xorg-server's `dix/devices.c`), so it has no scroll
231+
valuator for the smooth XI2 path real devices use.
232+
233+
**So X11 animates in notches, and a scroll worth one notch is not animated at
234+
all.** The default `scroll.scroll_step` of 50 pixels is exactly one notch there,
235+
so a plain `scroll_down` on X11 arrives as the single wheel click it always did
236+
— deliberately, and immediately: delivering it late would be added latency and
237+
nothing else. From two notches up (`scroll_step_half`, `scroll_step_full`, or a
238+
`scroll_step` above 60) the same eased curve applies as everywhere else, and
239+
those are the scrolls the animation is worth having for.
240+
241+
Neru sends the same distance on every backend; only the granularity of a step
242+
differs — though on Wayland the animated path spends that distance as a
243+
continuous delta where the unanimated one spends it as notches, and an
244+
application may scale the two differently, so switching the animation on can
245+
change how far a scroll reaches there. Wayland steps also declare axis source
246+
`continuous` rather than `wheel`, because a wheel source invites a toolkit to
247+
round the fraction back to a detent.
248+
249+
Measured on wlroots (sway) by
250+
`TestScrollAtCursor_DeliversSubNotchStepsWithSmoothScroll`, which maps a real
251+
`xdg-shell` window and reads what the compositor delivers to it. **The X11 and
252+
KDE conclusions are read from the sources named above and are not measured on
253+
hardware**, and neither is the uinput `REL_WHEEL_HI_RES` route — the
254+
headless-sway job reads no input devices at all, so nothing written to a uinput
255+
device reaches the compositor there.
256+
222257
### Notes on the ⚠️ entries
223258

224259
**Focused app on Wayland.** wlroots and KWin resolve the focused window through
@@ -549,7 +584,7 @@ discovery rather than the mode itself.
549584
| **Recursive grid**| Transition animation ||||
550585
| **Recursive grid**| Virtual pointer indicator ||||
551586
| **Recursive grid**| Sub-key preview | ✅ mini-grid of next keys | ✅ mini-grid of next keys | ✅ mini-grid of next keys |
552-
| **Scroll** | Smooth scroll animation || ||
587+
| **Scroll** | Smooth scroll animation || ✅ (X11: whole notches) ||
553588
| **Monitor select**| Whole mode | ✅ native panels | ✅ Cairo panels | 🟡 `CodeNotSupported` |
554589

555590
Everything else is shared: multi-letter labels, label direction, hide-unmatched,
@@ -734,10 +769,10 @@ green in every cell while an option means nothing, which is exactly how
734769
| `smooth_cursor.max_duration` | option |||| cursor movement is not animated on Windows |
735770
| `smooth_cursor.duration_per_pixel` | option |||| cursor movement is not animated on Windows |
736771
| `smooth_cursor.relative_movement_duration` | option |||| cursor movement is not animated on Windows |
737-
| `smooth_scroll.enabled` | option || || only the darwin scroll animator reads these; elsewhere the scroll is injected in one step |
738-
| `smooth_scroll.steps` | option || || only the darwin scroll animator reads these; elsewhere the scroll is injected in one step |
739-
| `smooth_scroll.max_duration` | option || || only the darwin scroll animator reads these; elsewhere the scroll is injected in one step |
740-
| `smooth_scroll.duration_per_pixel` | option || || only the darwin scroll animator reads these; elsewhere the scroll is injected in one step |
772+
| `smooth_scroll.enabled` | option || || the Windows scroll is injected in one step; macOS and Linux animate it, and on X11 the steps are whole wheel notches because X has no smaller scroll to send |
773+
| `smooth_scroll.steps` | option || || the Windows scroll is injected in one step; macOS and Linux animate it, and on X11 the steps are whole wheel notches because X has no smaller scroll to send |
774+
| `smooth_scroll.max_duration` | option || || the Windows scroll is injected in one step; macOS and Linux animate it, and on X11 the steps are whole wheel notches because X has no smaller scroll to send |
775+
| `smooth_scroll.duration_per_pixel` | option || || the Windows scroll is injected in one step; macOS and Linux animate it, and on X11 the steps are whole wheel notches because X has no smaller scroll to send |
741776
| `--split-word` | mode flag |||| splitting detected text into words needs the vision strategy, which only macOS has an engine for; elsewhere the flag is refused rather than ignored |
742777
| `--strategy=vision` | mode flag |||| no element-detection engine outside macOS answers the vision strategy, so detection returns nothing and no hints appear; use axtree |
743778
| `hide_cursor` | action |||| a Wayland client may not hide another client's cursor, and the blessed Linux stack is Wayland; Windows has no equivalent either |
@@ -763,10 +798,16 @@ whatever the [Capability Matrix](#capability-matrix) currently reports
763798
| Screen-sharing hide | macOS | `platform/darwin/overlay_darwin.m` | NSWindow sharing level is a Quartz concept |
764799
| Secure input detection | macOS | `platform/darwin/secureinput.go` | `CGSessionCopyCurrentDictionary`, a private API; neither X11 nor Wayland has the concept |
765800

766-
Two entries left this table in ADR 0013. **Smooth scroll animation** was
767-
recorded as needing "a synthesizable continuous scroll event stream"; uinput has
768-
`REL_WHEEL_HI_RES`, `wl_pointer` axis values are continuous and libei carries
769-
scroll deltas, so it is a Linux gap pending a spike. The **Vision (OCR) hint
801+
Two entries left this table in ADR 0013 and neither is coming back.
802+
**Smooth scroll animation** was recorded as needing "a synthesizable continuous
803+
scroll event stream"; the spike found one on Wayland — a
804+
`zwlr_virtual_pointer_v1` axis event with no discrete step count, and libei's
805+
pixel-precise scroll delta on KWin — and it now animates on every Linux backend,
806+
with X11 limited to whole notches for the reason footnote ⁴ of the
807+
[Capability Matrix](#capability-matrix) gives. A limit on one backend is not an
808+
exclusive: it is that backend's documented limit, which is what
809+
[ADR 0013](./adr/0013-parity-is-measured-in-words-not-subsystems.md) says the
810+
non-blessed stacks carry. The **Vision (OCR) hint
770811
strategy** was recorded as needing macOS-only `VNRequest` APIs; the API is
771812
macOS-only but the capability is not, so it is a Linux gap too — met by an OCR
772813
engine linked the way every other native dependency here is, with its language
@@ -794,40 +835,36 @@ command — that means less here than it does on macOS, whether or not the
794835

795836
1. `neru docs` — returns `CodeNotSupported` although the tray already opens
796837
URLs through `xdg-open` in the same repo
797-
2. Smooth scroll animation — not implemented, and `smooth_scroll.*` is parsed,
798-
validated and then silently ignored. Spike `REL_WHEEL_HI_RES` (uinput),
799-
continuous `wl_pointer` axis values and libei scroll deltas before
800-
committing
801-
3. Hints search input badge — not drawn; the overlay manager reports
838+
2. Hints search input badge — not drawn; the overlay manager reports
802839
`CodeNotSupported` and the query goes on reaching hints through the event
803840
tap's key stream
804-
4. Screen capture — no code path anywhere in the tree. Prerequisite for the OCR
841+
3. Screen capture — no code path anywhere in the tree. Prerequisite for the OCR
805842
strategy below and the missing half of `ports.Vision`. Take it per backend:
806843
`wlr-screencopy` on wlroots, `XGetImage` on X11, the portal only for KDE
807-
5. `vision` hint strategy — no engine. Met by linking one through
844+
4. `vision` hint strategy — no engine. Met by linking one through
808845
`#cgo pkg-config`, as every other native dependency here is, with the engine
809846
added to the required Linux library list and its language data checked at
810847
use so a missing `tessdata` reports `CodeNotSupported` naming what is
811848
absent. Note the strategy is wider than OCR: macOS also runs rectangle
812849
detection and saliency, which no OCR engine answers, so
813850
`hints.vision.detect_rectangles` and the four `rectangle_*` options are
814-
declared macOS-only and Linux `vision` is text-only. Needs 4
815-
6. X11 unmodified scroll — a scroll with no `--modifier` presses nothing, so the
851+
declared macOS-only and Linux `vision` is text-only. Needs 3
852+
5. X11 unmodified scroll — a scroll with no `--modifier` presses nothing, so the
816853
`XTestFakeButtonEvent` still carries whatever the X server records the user as
817854
physically holding. Binding `Ctrl+J` to a plain `scroll_down` therefore sends
818855
ctrl+scroll for as long as ctrl is down. macOS forces the empty set onto the
819856
event instead; a real-key backend has no per-event field to zero, so closing
820857
this means reading the live key state through `XQueryKeymap` in the C bridge
821-
7. KDE RemoteDesktop portal grant — does not survive a daemon restart, so the
858+
6. KDE RemoteDesktop portal grant — does not survive a daemon restart, so the
822859
consent prompt returns on every start
823-
8. Grid virtual-pointer indicator — a no-op on Linux, while recursive grid
860+
7. Grid virtual-pointer indicator — a no-op on Linux, while recursive grid
824861
draws it on all three platforms
825-
9. `FocusedWindowBounds` — returns not-found on KWin, so callers silently fall
862+
8. `FocusedWindowBounds` — returns not-found on KWin, so callers silently fall
826863
back to the active screen
827-
10. Wayland global hotkeys — a setup requirement rather than missing code: they
828-
need `input`-group membership and a CGO build. Failing loudly with the
829-
remedy, and documenting it as a first-class setup step, is the work
830-
11. Tail — the tray tooltip is a no-op (dbusmenu carries no such property), the
864+
9. Wayland global hotkeys — a setup requirement rather than missing code: they
865+
need `input`-group membership and a CGO build. Failing loudly with the
866+
remedy, and documenting it as a first-class setup step, is the work
867+
10. Tail — the tray tooltip is a no-op (dbusmenu carries no such property), the
831868
tray has one icon for both running and paused states where macOS has two,
832869
and the `CGO_ENABLED=0` build should announce its boundary once at startup
833870
rather than failing feature by feature
@@ -1065,13 +1102,23 @@ violation fails `just test` rather than review:
10651102
| `*_linux_wayland.go` | Wayland |
10661103
| `*_linux_wayland_<compositor>.go` | one compositor family needing a distinct path |
10671104
| `*_cgo.go` / `*_nocgo.go` | CGO and pure-Go variants of the same slot |
1105+
| `*_integration_cgo.go` | cgo scaffolding for an integration test, `//go:build … && integration` so it never ships |
10681106

10691107
Inside a package that is already one platform (`adapter/*/darwin`,
10701108
`adapter/*/linux`, `adapter/platform/windows`, …) the OS token is dropped —
10711109
the directory carries it. `overlay/linux/wayland_cgo.go` and
10721110
`platform/linux/system_x11_cgo.go` keep only the axes that still vary; a
10731111
`system_linux_x11_cgo.go` inside `platform/linux/` would say linux twice.
10741112

1113+
The `*_integration_cgo.go` row exists for one situation and should stay rare:
1114+
Go rejects `import "C"` in a `_test.go` file outright, so an integration test
1115+
that needs C — `accessibility/native/linux/scroll_probe_integration_cgo.go`
1116+
mapping a Wayland window to measure what a compositor delivers — has to put it
1117+
in a non-test file. The `integration` term is what keeps that file out of every
1118+
build the product is made from, and the C stays inline in the cgo preamble
1119+
rather than in a `.c` file beside it, because a `.c` file compiles into the
1120+
package unconditionally.
1121+
10751122
That is why the four Linux backend rows above hold no files today: every Linux
10761123
backend split in the tree lives inside a single-platform directory and has
10771124
dropped the token. The rows are the spelling to use if a mixed package ever

docs/DEVELOPMENT.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,20 +221,21 @@ environment rather than as a wall of test failures that look like Neru's.
221221

222222
What it does not cover:
223223

224-
- **Linux desktop-driving behavior, yet.** Every test that reads
225-
`NERU_DESKTOP_TESTS` today is a macOS one, and Linux has a single integration
226-
test at all — the fontconfig resolver. So this leg currently proves the
227-
environment exists and re-runs the suite inside it; it starts paying the
228-
moment a Linux `*_integration_linux_test.go` lands. That is the point of
229-
building it first: a Linux desktop test written before anything can execute
230-
it is a test written on faith.
224+
- **Most Linux desktop-driving behavior, still.** The leg has started paying:
225+
`scroll_smooth_integration_linux_test.go` maps a real xdg-shell window in the
226+
session and measures what the compositor delivers to it, which is how the
227+
smooth-scroll claim in ADR 0013's amendment is checked rather than argued.
228+
Everything else that reads `NERU_DESKTOP_TESTS` is still a macOS test.
231229
- **X11 and KDE.** Xwayland is disabled in the session on purpose, so `DISPLAY`
232230
is unset and backend detection has only the Wayland answer. An Xvfb leg is
233231
worth having and is deliberately second — under ADR 0013 X11 owes capability
234232
parity rather than behavioral parity.
235233
- **Anything needing device access.** The runner has no `/dev/dri` (the
236234
compositor runs the pixman renderer) and no `input` group membership, so the
237-
evdev-backed paths cannot run.
235+
evdev-backed paths cannot run. The compositor would not see them either:
236+
`WLR_LIBINPUT_NO_DEVICES=1` on the headless backend means it reads no input
237+
devices, so nothing written to a uinput device reaches it whatever the
238+
permissions are. What is observable here is the `zwlr_virtual_pointer` path.
238239
- **A verdict.** The leg is **advisory**: `continue-on-error` keeps it off the
239240
merge button, and its result is a job summary counting failures, packages
240241
reporting `FAIL`, and every skip with the reason it gave. It is advisory

0 commit comments

Comments
 (0)