- Go 100%
-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. |
||
|---|---|---|
| .gitignore | ||
| device.go | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| mage.go | ||
| magefile.go | ||
| main.go | ||
| README.md | ||
| uinput.go | ||
| uinput_test.go | ||
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_eventlayout 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 expecteddyand the correct window-relative pointer position, logged by the application itself rather than inferred from pixels -
-move 100,0moved the pointer 75px and-move 0,80moved 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 carryREL_WHEEL,REL_HWHEELandBTN_LEFT -
cosmic-compopens the new event node -
an X11 client (
xev) receivesButtonPress/ButtonReleasefor 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.