Skip to content

feat: XI2.1 smooth scrolling via xf86-input-neko scroll valuators#196

Open
hiroTamada wants to merge 7 commits intomainfrom
hiro/xi2-scroll
Open

feat: XI2.1 smooth scrolling via xf86-input-neko scroll valuators#196
hiroTamada wants to merge 7 commits intomainfrom
hiro/xi2-scroll

Conversation

@hiroTamada
Copy link
Copy Markdown
Contributor

@hiroTamada hiroTamada commented Apr 1, 2026

Checklist

  • Requires kernel/neko#hiro/xi2-scroll to be merged and the ghcr.io/kernel/neko/base:xi2-scroll image to be published
  • A description of the changes proposed in the pull request.
  • @mentions of the person or team responsible for reviewing proposed changes.

Summary

Replace the XTest button-event scroll path with XInput2.1 smooth scroll valuators on the xf86-input-neko Xorg driver. This fixes the fundamental scrolling problem: XTest could only fire discrete "notch" events (~100px per click in Chromium), making it impossible to achieve smooth trackpad scrolling. With XI2.1, we get pixel-precise 1:1 mapping between client trackpad deltas and browser scroll pixels.

Problem

The old XTest approach (xf86PostButtonEvent with buttons 4/5) had a fixed scroll quantum — each button press scrolled ~100px in Chromium with no sub-notch precision. This meant:

  • pixelsPerNotch too low → fires many button clicks → scrolls too fast
  • pixelsPerNotch too high → needs lots of delta before firing → feels jumpy/unresponsive
  • No middle ground exists because each click always scrolls the same fixed amount

Solution

Use XInput2.1 scroll valuators which provide continuous, sub-pixel scroll precision:

xf86-input-neko driver (neko.c):

  • Add vertical/horizontal scroll valuator axes (3, 4) with SetScrollValuator
  • Handle NEKO_SCROLL (0x80) messages via xf86PostMotionEventM instead of button events
  • Change device type from XI_TOUCHSCREEN to XI_MOUSE so Chromium respects scroll valuators

Client (video.vue / base.ts):

  • Remove PIXELS_PER_TICK quantization and 100ms throttle
  • Send raw pixel deltas batched per requestAnimationFrame
  • Use document-level wheel listener with passive: false for reliable preventDefault()
  • Extend binary wheel message to include controlKey byte (length=5)

Config:

  • Enable xinput driver in neko.yaml with socket path
  • Use neko base image with XI2 scroll support

Test results

Programmatic tests confirm exact 1:1 pixel mapping:

Client deltaY Actual scrollY Per-event
3 (tiny trackpad) 3px 3.0px
10 (small) 10px 10.0px
50 (medium) 50px 50.0px
200 (fast swipe) 200px 200.0px

Depends on

Made with Cursor


Note

Medium Risk
Touches the end-to-end input path (client wheel handling, binary protocol framing, and the Xorg xf86-input-neko driver), so regressions could break scrolling or other pointer interactions. Changes are localized but depend on consistent protocol/driver behavior across environments.

Overview
Switches scrolling from the old discrete wheel/button-style behavior to XI2.1 smooth scrolling by sending raw wheel deltas through to new scroll valuators in the xf86-input-neko Xorg driver.

On the client, wheel capture is moved to a document-level non-passive listener (to reliably preventDefault), scroll deltas are no longer quantized/throttled, and wheel messages now include a controlKey flag; the scroll_invert default is flipped to false.

On the runtime side, the image is bumped to ghcr.io/kernel/neko/base:3.0.8-v1.5.0, neko.yaml enables the input socket, and the Xorg driver adds vertical/horizontal scroll axes (valuators 3/4), handles NEKO_SCROLL via motion events, and reports the device as XI_MOUSE.

Written by Cursor Bugbot for commit c2fea7d. This will update automatically on new commits. Configure here.

Replace the XTest button-event scroll path (fixed ~100px quantum per
click) with XInput2.1 scroll valuators on the xf86-input-neko driver.
This gives pixel-precise 1:1 mapping between client trackpad deltas
and browser scroll pixels, eliminating the jumpiness of discrete
scroll notches.

Changes:
- neko.c: add vertical/horizontal scroll valuator axes (3, 4) with
  SetScrollValuator; handle NEKO_SCROLL messages via
  xf86PostMotionEventM instead of button events; change device type
  from XI_TOUCHSCREEN to XI_MOUSE so Chromium respects valuators
- video.vue: remove PIXELS_PER_TICK quantization and 100ms throttle;
  send raw pixel deltas batched per rAF; use document-level wheel
  listener with passive:false for reliable preventDefault
- base.ts: extend wheel binary message to include controlKey byte
  (PayloadScrollWithCtrl, length=5)
- neko.yaml: enable xinput driver with socket path
- Dockerfile: use neko base image with XI2 scroll support

Requires: kernel/neko branch hiro/xi2-scroll
Made-with: Cursor
- Scope wheel listener to overlay element instead of document to avoid
  blocking scrolling in chat/emoji/files panels
- Store handler reference and remove it in beforeDestroy to prevent leaks
- Re-integrate scroll sensitivity setting (1-100 range, 50 = 1x)
- Remove unused scroll_x/scroll_y fields from xf86-input-neko driver
- Revert Dockerfile FROM to existing neko base tag for CI (pending neko#12)

Made-with: Cursor

FROM ghcr.io/kernel/neko/base:3.0.8-v1.4.0 AS neko
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
# TODO: update to xi2-scroll tag once kernel/neko#12 is merged and tagged
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will update this once neko PR is merged

- Cancel any pending requestAnimationFrame in beforeDestroy to prevent
  accessing torn-down Vue instance
- Restore sendMousePos call in onWheel so cursor position is synced
  before scroll events (trackpad scrolling doesn't fire mousemove)

Made-with: Cursor
The default scroll setting is 10, so the divisor should be 10 (not 50)
to produce 1x sensitivity at the default value. Range: 1=0.1x to 100=10x.

Made-with: Cursor
Call preventDefault() before the locked check so the page doesn't
scroll underneath the video overlay when hosting but locked.

Made-with: Cursor
- Subtract sent dx/dy from accumulators instead of resetting to zero,
  preserving fractional remainders for accurate sensitivity scaling
- Use assignment for ctrl flag to reflect latest event state, avoiding
  false positives from transient ctrl events within a RAF batch

Made-with: Cursor
@hiroTamada hiroTamada requested review from Sayan- and rgarcia April 1, 2026 20:42
Copy link
Copy Markdown
Contributor

@rgarcia rgarcia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

hiroTamada added a commit to kernel/neko that referenced this pull request Apr 2, 2026
## Summary

Enable pixel-precise smooth scrolling by sending raw pixel deltas
through the `xf86-input-neko` xinput driver instead of quantizing them
into discrete XTest button events.

### Changes

**`server/internal/desktop/xorg.go`** — `Scroll()` now sends raw pixel
deltas directly to the xinput driver via Unix socket. Falls back to
XTest if the driver connection fails.

**`server/internal/webrtc/legacyhandler.go`** — Support
`PayloadScrollWithCtrl` (length=5) binary message format that includes a
`controlKey` byte alongside `deltaX`/`deltaY`.

**`server/pkg/xinput/`** — Add `NEKO_SCROLL` (0x80) message type and
`Scroll(deltaX, deltaY int32)` method to send scroll events over the
Unix socket to the Xorg driver.

**`server/pkg/xorg/xorg.c`** — Fix XTest fallback scroll direction
(button 4/5 mapping was inverted).

**`server/internal/config/desktop.go`** — Add `CDPScrollURL` config
option (unused by default, for optional CDP scroll bypass).

**`server/pkg/cdpscroll/`** — Optional CDP-based scroll client (gated
behind `CDPScrollURL` config, not active by default).

### Context

Used by
[kernel/kernel-images#196](kernel/kernel-images#196)
which extends the `xf86-input-neko` Xorg driver with XI2.1 scroll
valuators. Together, this achieves 1:1 pixel mapping between client
trackpad deltas and browser scroll.

Made with [Cursor](https://cursor.com)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes desktop input/scroll handling (including modifier behavior and
X11 button mapping), which can impact core interaction and has
platform-specific edge cases. Failover paths mitigate risk but need
validation across driver/no-driver setups.
> 
> **Overview**
> Adds XI2.1 *smooth/pixel-precise* scrolling support by routing scroll
deltas through the `xinput` Unix-socket driver when
`desktop.input.enabled` is set, including temporarily setting the Ctrl
modifier before posting the event and falling back to XTest on driver
errors.
> 
> Extends the legacy WebRTC data-channel scroll payload to optionally
include a Ctrl flag (`Length == 5`) while keeping the existing scroll
format for compatibility, and updates logging accordingly.
> 
> Expands `server/pkg/xinput` with a new `NEKO_SCROLL` message and
`Driver.Scroll()` API (dummy returns a not-connected error), and fixes
the XTest fallback scroll direction mapping in `server/pkg/xorg/xorg.c`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ae3594a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ivity

- Bump neko base image to 3.0.8-v1.5.0 (XI2.1 smooth scrolling support)
- Default scroll_invert to false (works with --disable-features=ScrollLatching)
- Add useDefineForClassFields: false to tsconfig for Vue 2 compatibility
- Use document-level wheel listener with capture: true for reliable interception
- Send scroll deltas directly without rAF batching or accumulation

Made-with: Cursor
@hiroTamada
Copy link
Copy Markdown
Contributor Author

Note: --disable-features=ScrollLatching Chromium flag required

With XI2.1 smooth scrolling, Chromium's scroll latching prevents nested scrolling from working (scroll events get "latched" to the first scrollable element and never propagate to parent containers). This is because XI2.1 continuous scroll events lack gesture phase information (GDK_SCROLL_SMOOTH without GDK_TOUCHPAD_GESTURE_PHASE_BEGIN/END), so Chromium can't detect when the inner element has reached its scroll boundary.

The fix is to pass --disable-features=ScrollLatching as a Chromium flag at deploy time, e.g.:

CHROMIUM_FLAGS="... --disable-features=ScrollLatching"

This should be added to the default flags in run-unikernel.sh (or equivalent deployment config) once this PR lands.

@hiroTamada
Copy link
Copy Markdown
Contributor Author

hiroTamada commented Apr 2, 2026

@rgarcia heads up — a few changes since your approval:

  1. Kept scroll dispatch simple — earlier commits on this branch added rAF batching and sub-pixel accumulation, but testing showed no perceptible difference. Removed them to keep the diff minimal — onWheel sends deltas directly on each wheel event, same as the original code.

  2. scroll_invert default changed to false — needed because we're adding --disable-features=ScrollLatching (see comment above), which changes how Chromium interprets scroll direction with XI2.1 valuators.

  3. useDefineForClassFields: false added to tsconfig.json — Vue 2 doesn't proxy class fields prefixed with _, so TypeScript class field declarations were silently broken. This flag restores the old [[Set]] behavior so fields go through Object.defineProperty and stay visible on this.

Net diff is now +10/-31 (simpler than before).

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

this.$client.sendData('wheel', { x, y })
const sensitivity = this.scroll / 10
const dx = Math.max(-32767, Math.min(32767, Math.round(x * sensitivity)))
const dy = Math.max(-32767, Math.min(32767, Math.round(y * sensitivity)))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small scroll deltas silently lost at low sensitivity

Medium Severity

At low scroll settings (1–4, i.e. sensitivity 0.1x–0.4x), Math.round(x * sensitivity) rounds small trackpad deltas to zero, silently dropping them. For example, at scroll=1 a deltaY of 4px yields round(0.4)=0 — the event is discarded entirely. The old code guaranteed at least one tick via || Math.sign(x). The PR discussion explicitly states accumulators were added in commit 11c1ba1 to preserve fractional remainders, but they are absent from the final code, so repeated small gestures never accumulate into a scroll action.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a concern in practice. The default scroll setting is 10 (sensitivity 1.0x), so deltas pass through unscaled. You'd need scroll set to 1–4 and sub-3px trackpad deltas for anything to be dropped — and at those settings, swallowing tiny deltas is arguably the intended behavior.

The || Math.sign(x) guarantee mentioned here didn't exist in the original code on main — the original onWheel sent raw deltas without sensitivity scaling.

@hiroTamada hiroTamada requested a review from rgarcia April 2, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants