Skip to content

Commit 18e7577

Browse files
authored
feat: XI2.1 smooth scrolling via xf86-input-neko scroll valuators (#196)
# Checklist - [ ] Requires [kernel/neko#hiro/xi2-scroll](https://github.com/kernel/neko/tree/hiro/xi2-scroll) to be merged and the `ghcr.io/kernel/neko/base:xi2-scroll` image to be published - [x] 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 - **neko fork**: [`kernel/neko@hiro/xi2-scroll`](kernel/neko@master...hiro/xi2-scroll) — server-side changes to send raw pixel deltas to xinput driver Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes the end-to-end input/scroll pipeline (client wheel handling, binary protocol, and Xorg input driver), which could regress scrolling or pointer/touch behavior across browsers/devices. > > **Overview** > Switches remote scrolling from discrete XTest-style “ticks” to XI2.1 scroll valuators for *smooth, pixel-precise* trackpad/mouse wheel input. > > The web client now captures wheel events at the document level (non-passive, capture) and sends scaled raw deltas (clamped to `int16`) plus a `controlKey` flag in an extended `wheel` data message; the old quantization/throttle logic and default scroll inversion are removed. > > The `xf86-input-neko` Xorg driver is extended with vertical/horizontal scroll valuator axes and a `NEKO_SCROLL` message path that posts motion events for scroll, and the image/config is updated to use the newer `neko/base` and enable the input socket in `neko.yaml`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ca24892. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 10dd568 commit 18e7577

7 files changed

Lines changed: 84 additions & 60 deletions

File tree

images/chromium-headful/Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
146146
rm -rf /tmp/ffmpeg*
147147
EOT
148148

149-
FROM ghcr.io/kernel/neko/base:3.0.8-v1.4.0 AS neko
150-
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
149+
FROM ghcr.io/kernel/neko/base:3.0.8-v1.5.0 AS neko
151150
FROM node:22-bullseye-slim AS node-22
152151
FROM docker.io/ubuntu:22.04
153152

images/chromium-headful/client/src/components/video.vue

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
:style="{ pointerEvents: hosting ? 'auto' : 'none' }"
1818
@click.stop.prevent
1919
@contextmenu.stop.prevent
20-
@wheel.stop.prevent="onWheel"
2120
@mousemove.stop.prevent="onMouseMove"
2221
@mousedown.stop.prevent="onMouseDown"
2322
@mouseup.stop.prevent="onMouseUp"
@@ -231,6 +230,8 @@
231230
import GuacamoleKeyboard from '~/utils/guacamole-keyboard.ts'
232231
233232
const WHEEL_LINE_HEIGHT = 19
233+
const SCROLL_SENSITIVITY_BASE = 10
234+
const INT16_MAX = 32767
234235
235236
@Component({
236237
name: 'neko-video',
@@ -248,6 +249,8 @@
248249
@Ref('player') readonly _player!: HTMLElement
249250
@Ref('video') readonly _video!: HTMLVideoElement
250251
@Ref('resolution') readonly _resolution!: Resolution
252+
253+
private _wheelHandler: ((e: WheelEvent) => void) | null = null
251254
@Ref('clipboard') readonly _clipboard!: Clipboard
252255
253256
// all controls are hidden (e.g. for cast mode)
@@ -527,6 +530,14 @@
527530
this.$nextTick(() => { this.isVideoSyncing = false })
528531
})
529532
533+
this._wheelHandler = (e: WheelEvent) => {
534+
if (!this.hosting) return
535+
e.preventDefault()
536+
if (this.locked) return
537+
this.onWheel(e)
538+
}
539+
document.addEventListener('wheel', this._wheelHandler, { passive: false, capture: true })
540+
530541
/* Initialize Guacamole Keyboard */
531542
this.keyboard.onkeydown = (key: number) => {
532543
if (!this.hosting || this.locked) {
@@ -552,6 +563,10 @@
552563
}
553564
554565
beforeDestroy() {
566+
if (this._wheelHandler) {
567+
document.removeEventListener('wheel', this._wheelHandler, { capture: true })
568+
this._wheelHandler = null
569+
}
555570
this.observer.disconnect()
556571
this.$accessor.video.setPlayable(false)
557572
/* Guacamole Keyboard does not provide destroy functions */
@@ -708,46 +723,28 @@
708723
})
709724
}
710725
711-
wheelThrottle = false
712726
onWheel(e: WheelEvent) {
713-
if (!this.hosting || this.locked) {
714-
return
715-
}
727+
this.sendMousePos(e)
716728
717729
let x = e.deltaX
718730
let y = e.deltaY
719731
720-
// Normalize to pixel units. deltaMode 1 = lines, 2 = pages; convert
721-
// both to approximate pixel values so the divisor below works uniformly.
722732
if (e.deltaMode !== 0) {
723733
x *= WHEEL_LINE_HEIGHT
724734
y *= WHEEL_LINE_HEIGHT
725735
}
726736
727737
if (this.scroll_invert) {
728-
x = x * -1
729-
y = y * -1
738+
x *= -1
739+
y *= -1
730740
}
731741
732-
// The server sends one XTestFakeButtonEvent per unit we pass here,
733-
// and each event scrolls Chromium by ~120 px. Raw pixel deltas from
734-
// trackpads are already in pixels (~120 per notch), so dividing by
735-
// PIXELS_PER_TICK converts them to discrete scroll "ticks". The
736-
// result is clamped to [-scroll, scroll] (the user-facing sensitivity
737-
// setting) so fast swipes don't over-scroll.
738-
const PIXELS_PER_TICK = 120
739-
x = x === 0 ? 0 : Math.min(Math.max(Math.round(x / PIXELS_PER_TICK) || Math.sign(x), -this.scroll), this.scroll)
740-
y = y === 0 ? 0 : Math.min(Math.max(Math.round(y / PIXELS_PER_TICK) || Math.sign(y), -this.scroll), this.scroll)
741-
742-
this.sendMousePos(e)
743-
744-
if (!this.wheelThrottle) {
745-
this.wheelThrottle = true
746-
this.$client.sendData('wheel', { x, y })
742+
const sensitivity = this.scroll / SCROLL_SENSITIVITY_BASE
743+
const dx = Math.max(-INT16_MAX, Math.min(INT16_MAX, Math.round(x * sensitivity)))
744+
const dy = Math.max(-INT16_MAX, Math.min(INT16_MAX, Math.round(y * sensitivity)))
747745
748-
window.setTimeout(() => {
749-
this.wheelThrottle = false
750-
}, 100)
746+
if (dx !== 0 || dy !== 0) {
747+
this.$client.sendData('wheel', { x: dx, y: dy, controlKey: e.ctrlKey || e.metaKey })
751748
}
752749
}
753750

images/chromium-headful/client/src/neko/base.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
136136
this._id = ''
137137
}
138138

139-
public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void
139+
public sendData(event: 'wheel', data: { x: number; y: number; controlKey?: boolean }): void
140+
public sendData(event: 'mousemove', data: { x: number; y: number }): void
140141
public sendData(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void
141142
public sendData(event: string, data: any) {
142143
if (!this.connected) {
@@ -156,12 +157,13 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
156157
payload.setUint16(5, data.y, true)
157158
break
158159
case 'wheel':
159-
buffer = new ArrayBuffer(7)
160+
buffer = new ArrayBuffer(8)
160161
payload = new DataView(buffer)
161162
payload.setUint8(0, OPCODE.SCROLL)
162-
payload.setUint16(1, 4, true)
163+
payload.setUint16(1, 5, true)
163164
payload.setInt16(3, data.x, true)
164165
payload.setInt16(5, data.y, true)
166+
payload.setUint8(7, data.controlKey ? 1 : 0)
165167
break
166168
case 'keydown':
167169
case 'mousedown':

images/chromium-headful/client/src/store/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface KeyboardLayouts {
1212
export const state = () => {
1313
return {
1414
scroll: get<number>('scroll', 10),
15-
scroll_invert: get<boolean>('scroll_invert', true),
15+
scroll_invert: get<boolean>('scroll_invert', false),
1616
autoplay: get<boolean>('autoplay', true),
1717
ignore_emotes: get<boolean>('ignore_emotes', false),
1818
chat_sound: get<boolean>('chat_sound', true),

images/chromium-headful/client/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"importHelpers": true,
88
"moduleResolution": "node",
99
"experimentalDecorators": true,
10+
"useDefineForClassFields": false,
1011
"esModuleInterop": true,
1112
"allowSyntheticDefaultImports": true,
1213
"sourceMap": true,

images/chromium-headful/neko.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
desktop:
55
screen: "1920x1080@25"
6+
input:
7+
enabled: true
8+
socket: "/tmp/xf86-input-neko.sock"
69

710
member:
811
provider: multiuser

images/chromium-headful/xorg-deps/xf86-input-neko/src/neko.c

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,15 @@
5454
#include <X11/keysym.h>
5555
#include <mipointer.h>
5656
#include <xserver-properties.h>
57+
#include <inputstr.h>
5758
#include <pthread.h>
5859

59-
#define MAX_USED_VALUATORS 3 /* x, y, pressure */
60-
#define TOUCH_MAX_SLOTS 10 /* max number of simultaneous touches */
60+
#define MAX_USED_VALUATORS 5 /* x, y, pressure, v-scroll, h-scroll */
61+
#define TOUCH_VALUATORS 3 /* touch only uses x, y, pressure */
62+
#define TOUCH_MAX_SLOTS 10 /* max number of simultaneous touches */
63+
64+
#define NEKO_SCROLL 0x80
65+
#define SCROLL_INCREMENT 120.0
6166

6267
struct neko_message
6368
{
@@ -149,16 +154,25 @@ ReadInput(InputInfoPtr pInfo)
149154
ValuatorMask *m = priv->valuators;
150155
valuator_mask_zero(m);
151156

152-
// do not send valuators if x and y are -1
153-
if (msg.x != -1 && msg.y != -1)
157+
if (msg.type == NEKO_SCROLL)
154158
{
155-
valuator_mask_set_double(m, 0, msg.x);
156-
valuator_mask_set_double(m, 1, msg.y);
157-
valuator_mask_set_double(m, 2, msg.pressure);
159+
if (msg.y != 0)
160+
valuator_mask_set_double(m, 3, (double)msg.y);
161+
if (msg.x != 0)
162+
valuator_mask_set_double(m, 4, (double)msg.x);
163+
xf86PostMotionEventM(pInfo->dev, FALSE, m);
164+
}
165+
else
166+
{
167+
// do not send valuators if x and y are -1
168+
if (msg.x != -1 && msg.y != -1)
169+
{
170+
valuator_mask_set_double(m, 0, msg.x);
171+
valuator_mask_set_double(m, 1, msg.y);
172+
valuator_mask_set_double(m, 2, msg.pressure);
173+
}
174+
xf86PostTouchEvent(pInfo->dev, msg.touchId, msg.type, 0, m);
158175
}
159-
160-
// TODO: extend to other types, such as keyboard and mouse
161-
xf86PostTouchEvent(pInfo->dev, msg.touchId, msg.type, 0, m);
162176
}
163177

164178
/* Close socket. */
@@ -181,11 +195,11 @@ InitTouch(InputInfoPtr pInfo)
181195
struct neko_priv *priv = pInfo->private;
182196

183197
const int nbtns = 11;
184-
const int naxes = 3;
198+
const int naxes = MAX_USED_VALUATORS; /* x, y, pressure, v-scroll, h-scroll */
185199

186200
unsigned char map[nbtns + 1];
187201
Atom btn_labels[nbtns];
188-
Atom axis_labels[naxes];
202+
Atom axis_labels[MAX_USED_VALUATORS];
189203

190204
// init button map
191205
memset(map, 0, sizeof(map));
@@ -209,10 +223,12 @@ InitTouch(InputInfoPtr pInfo)
209223
btn_labels[10] = XIGetKnownProperty(BTN_LABEL_PROP_BTN_BACK);
210224

211225
// init axis labels
212-
memset(axis_labels, 0, ARRAY_SIZE(axis_labels) * sizeof(Atom));
226+
memset(axis_labels, 0, sizeof(axis_labels));
213227
axis_labels[0] = XIGetKnownProperty(AXIS_LABEL_PROP_ABS_MT_POSITION_X);
214228
axis_labels[1] = XIGetKnownProperty(AXIS_LABEL_PROP_ABS_MT_POSITION_Y);
215229
axis_labels[2] = XIGetKnownProperty(AXIS_LABEL_PROP_ABS_MT_PRESSURE);
230+
axis_labels[3] = XIGetKnownProperty(AXIS_LABEL_PROP_REL_VSCROLL);
231+
axis_labels[4] = XIGetKnownProperty(AXIS_LABEL_PROP_REL_HSCROLL);
216232

217233
/* initialize mouse emulation valuators */
218234
if (InitPointerDeviceStruct((DevicePtr)pInfo->dev,
@@ -274,22 +290,28 @@ InitTouch(InputInfoPtr pInfo)
274290
priv->pmax + 1, /* max_res */
275291
Absolute);
276292

277-
/*
278-
The mode field is either XIDirectTouch for direct−input touch devices
279-
such as touchscreens or XIDependentTouch for indirect input devices such
280-
as touchpads. For XIDirectTouch devices, touch events are sent to window
281-
at the position the touch occured. For XIDependentTouch devices, touch
282-
events are sent to the window at the position of the device's sprite.
283-
284-
The num_touches field defines the maximum number of simultaneous touches
285-
the device supports. A num_touches of 0 means the maximum number of
286-
simultaneous touches is undefined or unspecified. This field should be
287-
used as a guide only, devices will lie about their capabilities.
288-
*/
293+
/* scroll valuator axes — relative, so min=max=0 */
294+
xf86InitValuatorAxisStruct(pInfo->dev, 3,
295+
axis_labels[3],
296+
NO_AXIS_LIMITS, NO_AXIS_LIMITS, /* no limits for scroll */
297+
0, 0, 0,
298+
Relative);
299+
SetScrollValuator(pInfo->dev, 3, SCROLL_TYPE_VERTICAL,
300+
SCROLL_INCREMENT, SCROLL_FLAG_PREFERRED);
301+
302+
xf86InitValuatorAxisStruct(pInfo->dev, 4,
303+
axis_labels[4],
304+
NO_AXIS_LIMITS, NO_AXIS_LIMITS,
305+
0, 0, 0,
306+
Relative);
307+
SetScrollValuator(pInfo->dev, 4, SCROLL_TYPE_HORIZONTAL,
308+
SCROLL_INCREMENT, SCROLL_FLAG_PREFERRED);
309+
310+
/* Touch class only uses the first 3 axes (x, y, pressure). */
289311
if (InitTouchClassDeviceStruct(pInfo->dev,
290312
priv->slots,
291313
XIDirectTouch,
292-
naxes) == FALSE)
314+
TOUCH_VALUATORS) == FALSE)
293315
{
294316
xf86IDrvMsg(pInfo, X_ERROR,
295317
"unable to allocate TouchClassDeviceStruct\n");
@@ -354,7 +376,7 @@ PreInit(__attribute__ ((unused)) InputDriverPtr drv,
354376
return BadValue;
355377
}
356378

357-
pInfo->type_name = (char*)XI_TOUCHSCREEN;
379+
pInfo->type_name = (char*)XI_MOUSE;
358380
pInfo->device_control = DeviceControl;
359381
pInfo->read_input = NULL;
360382
pInfo->control_proc = NULL;

0 commit comments

Comments
 (0)