Skip to content

Commit 41de6d6

Browse files
authored
feat: XI2.1 smooth scroll support via xinput driver (#12)
## 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 -->
1 parent abe9ac5 commit 41de6d6

8 files changed

Lines changed: 78 additions & 18 deletions

File tree

server/internal/desktop/manager.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type DesktopManagerCtx struct {
3737
func New(config *config.Desktop) *DesktopManagerCtx {
3838
var input xinput.Driver
3939
if config.UseInputDriver {
40+
log.Info().Str("socket", config.InputSocket).Msg("using xinput driver for scroll")
4041
input = xinput.NewDriver(config.InputSocket)
4142
} else {
4243
input = xinput.NewDummy()

server/internal/desktop/xorg.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,21 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
1919
}
2020

2121
func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
22-
xorg.Scroll(deltaX, deltaY, controlKey)
22+
if manager.config.UseInputDriver {
23+
// XI2.1 smooth scrolling via xf86-input-neko: set modifier before the
24+
// driver posts the motion event so the X server sees Ctrl held.
25+
if controlKey {
26+
xorg.SetKeyboardModifier(xorg.KbdModControl, true)
27+
defer xorg.SetKeyboardModifier(xorg.KbdModControl, false)
28+
}
29+
if err := manager.input.Scroll(int32(deltaX), int32(deltaY)); err != nil {
30+
manager.logger.Warn().Err(err).Msg("xinput scroll failed, falling back to XTest")
31+
xorg.Scroll(deltaX, deltaY, false)
32+
}
33+
} else {
34+
// XTest fallback — handles controlKey atomically under a single X11 lock
35+
xorg.Scroll(deltaX, deltaY, controlKey)
36+
}
2337
}
2438

2539
func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {

server/internal/webrtc/legacyhandler.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package webrtc
33
import (
44
"bytes"
55
"encoding/binary"
6-
"strconv"
76

87
"github.com/m1k1o/neko/server/pkg/types"
98

@@ -35,6 +34,13 @@ type PayloadScroll struct {
3534
Y int16
3635
}
3736

37+
type PayloadScrollWithCtrl struct {
38+
PayloadHeader
39+
DeltaX int16
40+
DeltaY int16
41+
ControlKey uint8
42+
}
43+
3844
type PayloadKey struct {
3945
PayloadHeader
4046
Key uint64 // TODO: uint32
@@ -72,18 +78,32 @@ func (manager *WebRTCManagerCtx) handleLegacy(
7278

7379
manager.desktop.Move(int(payload.X), int(payload.Y))
7480
case OP_SCROLL:
75-
payload := &PayloadScroll{}
76-
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
77-
return err
78-
}
81+
if header.Length == 5 {
82+
payload := &PayloadScrollWithCtrl{}
83+
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
84+
return err
85+
}
86+
87+
logger.Trace().
88+
Int16("deltaX", payload.DeltaX).
89+
Int16("deltaY", payload.DeltaY).
90+
Bool("controlKey", payload.ControlKey != 0).
91+
Msg("scroll")
92+
93+
manager.desktop.Scroll(int(payload.DeltaX), int(payload.DeltaY), payload.ControlKey != 0)
94+
} else {
95+
payload := &PayloadScroll{}
96+
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
97+
return err
98+
}
7999

80-
logger.
81-
Trace().
82-
Str("x", strconv.Itoa(int(payload.X))).
83-
Str("y", strconv.Itoa(int(payload.Y))).
84-
Msg("scroll")
100+
logger.Trace().
101+
Int16("x", payload.X).
102+
Int16("y", payload.Y).
103+
Msg("scroll")
85104

86-
manager.desktop.Scroll(int(payload.X), int(payload.Y), false)
105+
manager.desktop.Scroll(int(payload.X), int(payload.Y), false)
106+
}
87107
case OP_KEY_DOWN:
88108
payload := &PayloadKey{}
89109
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {

server/internal/webrtc/manager.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,6 @@ func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.Sess
478478
//
479479

480480
if viper.GetBool("legacy") {
481-
// handle legacy data channel
482481
dc.OnMessage(func(message webrtc.DataChannelMessage) {
483482
if err := manager.handleLegacy(logger, message.Data, session); err != nil {
484483
logger.Err(err).Msg("data handle failed")

server/pkg/xinput/dummy.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package xinput
22

3-
import "time"
3+
import (
4+
"errors"
5+
"time"
6+
)
7+
8+
var errNotConnected = errors.New("xinput driver not connected")
49

510
type dummy struct{}
611

@@ -29,3 +34,7 @@ func (d *dummy) TouchUpdate(touchId uint32, x, y int, pressure uint8) error {
2934
func (d *dummy) TouchEnd(touchId uint32, x, y int, pressure uint8) error {
3035
return nil
3136
}
37+
38+
func (d *dummy) Scroll(deltaX, deltaY int32) error {
39+
return errNotConnected
40+
}

server/pkg/xinput/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const (
1212
XI_TouchBegin = 18
1313
XI_TouchUpdate = 19
1414
XI_TouchEnd = 20
15+
NEKO_SCROLL = 0x80
1516
)
1617

1718
type Message struct {
@@ -58,4 +59,7 @@ type Driver interface {
5859
TouchBegin(touchId uint32, x, y int, pressure uint8) error
5960
TouchUpdate(touchId uint32, x, y int, pressure uint8) error
6061
TouchEnd(touchId uint32, x, y int, pressure uint8) error
62+
// scroll via XI2 scroll valuators in the xf86-input-neko driver.
63+
// deltaX/deltaY are in scroll units (120 = one notch).
64+
Scroll(deltaX, deltaY int32) error
6165
}

server/pkg/xinput/xinput.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,16 @@ func (d *driver) TouchEnd(touchId uint32, x, y int, pressure uint8) error {
120120
_, err := d.conn.Write(msg.Pack())
121121
return err
122122
}
123+
124+
func (d *driver) Scroll(deltaX, deltaY int32) error {
125+
d.mu.Lock()
126+
defer d.mu.Unlock()
127+
128+
msg := Message{
129+
_type: NEKO_SCROLL,
130+
x: deltaX,
131+
y: deltaY,
132+
}
133+
_, err := d.conn.Write(msg.Pack())
134+
return err
135+
}

server/pkg/xorg/xorg.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ void XScroll(int deltaX, int deltaY) {
3535

3636
int ydir;
3737
if (deltaY > 0) {
38-
ydir = 4; // button 4 is up
38+
ydir = 5; // positive = scroll down = button 5
3939
} else {
40-
ydir = 5; // button 5 is down
40+
ydir = 4; // negative = scroll up = button 4
4141
}
4242

4343
int xdir;
4444
if (deltaX > 0) {
45-
xdir = 6; // button 6 is right
45+
xdir = 7; // positive = scroll right = button 7
4646
} else {
47-
xdir = 7; // button 7 is left
47+
xdir = 6; // negative = scroll left = button 6
4848
}
4949

5050
for (int i = 0; i < abs(deltaY); i++) {

0 commit comments

Comments
 (0)