No description
Find a file
Philip Chapman 9ca407a75c uwheel: add relative pointer motion
-move dx,dy sends REL_X/REL_Y in one report. Motion is relative because uinput
has no absolute positioning, and it goes through pointer acceleration, so
dx,dy is a request rather than a measured distance.

Proven this time by the application, not by pixels: a Fyne window logged 6 of 6
scroll events with the expected dy and the correct pointer position, and -move
moved the cursor 75px for a 100px request.
2026-10-30 14:02:21 -05:00
.gitignore uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
device.go uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
go.mod uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
go.sum uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
LICENSE Initial commit 2026-10-30 13:21:14 -05:00
mage.go uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
magefile.go uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00
main.go uwheel: add relative pointer motion 2026-10-30 14:02:21 -05:00
README.md uwheel: add relative pointer motion 2026-10-30 14:02:21 -05:00
uinput.go uwheel: add relative pointer motion 2026-10-30 14:02:21 -05:00
uinput_test.go uwheel: scroll-wheel events through a virtual uinput device 2026-10-30 13:35:10 -05:00

uwheel

Send scroll-wheel events on Linux by creating a virtual input device on /dev/uinput.

uwheel                 # scroll down one notch
uwheel -n 3            # scroll down three notches
uwheel -up -n 5        # scroll up five notches
uwheel -x              # horizontal

uwheel -move 100,-40   # move the pointer right 100, up 40
uwheel -move 20,0 -n 2 # move, then scroll

Both the motion and the scroll go to whatever window the pointer is over. Motion is relative and goes through the system pointer acceleration curve, so -move 100,0 typically travels less than 100 pixels; measure if it matters.

Why this exists

There was no portable way to scroll a window from a script on a Wayland session:

approach problem
xdotool click 4 XTEST goes through Xwayland's emulated-input (libei) portal, which raises a permission dialog with no "remember" option — and on COSMIC the window never gains input focus, so keys never arrive
ydotool 0.1.8 click accepts only buttons 1-3; there is no wheel event
compositor-specific tools differ per desktop, and every one is a new dependency

Writing to the kernel input layer is the same on every compositor, needs no portal, and raises no dialog. That is the whole point.

Requirements

  • Linux, 64-bit (the input_event layout is 24 bytes; a 32-bit build would need a different struct and is refused at runtime)
  • write access to /dev/uinput

Most distributions ship a udev rule granting input group (or a seated user) access to /dev/uinput. On a system where the device is root:root 0600, add:

# /etc/udev/rules.d/60-uinput.rules
KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="input"

and make sure the uinput module is loaded (modprobe uinput, or /etc/modules-load.d/uinput.conf with a line reading uinput).

Check what you have:

getfacl -p /dev/uinput        # an ACL for your user, or group input with rw
ls -l /dev/input/event*       # the device shows up here once created

Device lifetime

The device exists only while the file descriptor is open. Closing it makes the kernel remove it, so a crashed or killed tool cannot leave a phantom pointer behind. The cost is that every invocation creates and destroys a device, so there is a settle delay (-settle, default 150ms) before events are sent — without it the first notch is reliably lost, because libinput has not finished reading the new device's capabilities.

Flags

flag default meaning
-n 1 notches to scroll
-up false scroll up instead of down
-x false horizontal axis
-dev /dev/uinput device path
-settle 150ms wait after creating the device
-hold 120ms wait after sending, before removing it
-v false report the created event node

Negative -n also scrolls up.

Build and test

go build -o uwheel .
go test ./...

The tests that need /dev/uinput skip when it is unavailable. They check the ioctl numbers against the _IOC encoding, the struct layouts, that the created device advertises REL_WHEEL/REL_HWHEEL/BTN_LEFT in sysfs, and that closing removes it.

mage            # build and test
mage ci:local   # fmt check, vet, test, lint, vuln

What is and is not proven

Verified on Pop!OS 24.04 / COSMIC (Wayland), kernel 6.x:

  • a Fyne window received 6 of 6 scroll events from one uwheel -n 6, with the expected dy and the correct window-relative pointer position, logged by the application itself rather than inferred from pixels

  • -move 100,0 moved the pointer 75px and -move 0,80 moved it 55px, so motion works and is subject to pointer acceleration

  • the device is created with the intended capabilities — sysfs reports ID_INPUT=1, ID_INPUT_MOUSE=1, and the capability bitmaps carry REL_WHEEL, REL_HWHEEL and BTN_LEFT

  • cosmic-comp opens the new event node

  • an X11 client (xev) receives ButtonPress/ButtonRelease for buttons 4/5, so the events survive the trip through the compositor into Xwayland

  • a Fyne window received scroll events at the expected position

Not verified: that the events reach the window the caller intends when two windows overlap. Nothing here can ask the compositor which surface it will pick — that is the compositor's decision, and on COSMIC a native-Wayland window sits above an Xwayland one regardless of what an X client believes about stacking. Earlier attempts to scroll one specific window were confounded by exactly that: the tool was fine, and the events were going to a window in front of the one being measured.

REL_X/REL_Y are advertised but never sent. Without a motion axis the device is not always classified as a pointer and its wheel events can be dropped; the axis makes the classification unambiguous. Advertising BTN_LEFT is for the same reason.