Skip to content

Latest commit

 

History

History
314 lines (240 loc) · 11 KB

File metadata and controls

314 lines (240 loc) · 11 KB

smoothscroll

Hold a button, move your trackball, scroll by pixels — not by notches.

Built for Arch + Wayland + Hyprland. It does not reimplement scrolling: it hands the job to libinput, which is the only component on the system that can emit true continuous-source scroll events.

Why this exists

libinput already has exactly the feature you want — scroll_method = on_button_down. Button scrolling emits axis events with source LIBINPUT_POINTER_AXIS_SOURCE_CONTINUOUS, where (per libinput.h) "a scroll value of 1 represents the equivalent relative motion of 1" and the sequence is terminated with a 0 event that GTK/Firefox use to trigger kinetic scrolling. That is pixel-precise, macOS-style scrolling, done inside the compositor's own input path.

If your scroll button is on the same device as your pointer, you do not need this program. Configure that device directly and stop reading.

On Omarchy Quattro (Lua config, ~/.config/hypr/input.lua):

hl.device({
  name = "ploopy-corporation-ploopy-adept-trackball-mouse",
  scroll_method = "on_button_down",
  scroll_button = 277,
})

On stock Hyprland (legacy .conf config):

device {
  name = ploopy-corporation-ploopy-adept-trackball-mouse
  scroll_method = on_button_down
  scroll_button = 277
}

libinput's one hard constraint is that the button and the motion must come from the same device. A QMK keyboard sending BTN_SIDE while a separate trackball supplies the motion can never satisfy that.

So smoothscroll grabs both devices exclusively and re-emits everything through a single merged virtual pointer. Hyprland then applies on_button_down to that device, and the constraint is satisfied. The daemon performs no scroll arithmetic whatsoever.

Momentum

Coasting comes from the same trick rather than from synthesized wheel events. On release, the virtual scroll button is held down while decaying REL_X/ REL_Y motion is injected at 125Hz; the release is sent once the velocity falls below min_velocity. libinput turns that into a real decaying continuous scroll with a correct terminating event — indistinguishable from a physical glide, and clients get proper kinetic behaviour for free.

Re-pressing the button mid-coast catches the fling instead of restarting it, as on macOS.

Install

1. Build and install the files

cargo build --release
sudo install -Dm755 target/release/smoothscroll /usr/local/bin/smoothscroll
sudo install -Dm644 smoothscroll.toml           /etc/smoothscroll.toml
sudo install -Dm644 smoothscroll.service        /etc/systemd/system/smoothscroll.service

2. Point the config at your devices

sudo smoothscroll --list

Copy the names into devices in /etc/smoothscroll.toml. Substring matching is case-insensitive, so "Aurora Sweep rev1 Mouse" matches splitkb.com Aurora Sweep rev1 Mouse. List only pointer nodes — a device named ... Mouse, never the bare keyboard node.

3. Tell Hyprland about the virtual pointer

This is the step that actually makes scrolling happen. Without it the daemon runs, merges devices correctly, and does nothing visible.

Omarchy Quattro uses Lua configs. hyprland.lua does require("hypr.input"), so ~/.config/hypr/input.lua is read and any leftover ~/.config/hypr/input.conf is ignored entirely. Add to ~/.config/hypr/input.lua:

hl.device({
  name = "smoothscroll-virtual-pointer",
  scroll_method = "on_button_down",
  scroll_button = 277,
  scroll_button_lock = false,
  sensitivity = -0.5,
  natural_scroll = true,
})

On stock Hyprland with legacy .conf configs, the same thing in input.conf:

device {
  name = smoothscroll-virtual-pointer
  scroll_method = on_button_down
  scroll_button = 277
}

Because the daemon grabs your physical devices exclusively, Hyprland only ever sees the virtual pointer — so sensitivity, natural_scroll and friends must live in this block. Any tuning still attached to the physical device is inert while the daemon runs.

4. Try it in the foreground first

sudo smoothscroll --debug --config /etc/smoothscroll.toml
hyprctl reload      # in another terminal, AFTER the daemon starts

The reload matters: the virtual device is created at startup and destroyed on exit, so the device{} config has to be applied to the current instance.

The test that it works: the cursor freezes while you hold the scroll button. That is libinput consuming motion for scrolling. If the cursor keeps moving, the device block is not being applied — check hyprctl configerrors and confirm hyprctl devices lists smoothscroll-virtual-pointer.

5. Enable the service

sudo systemctl enable --now smoothscroll
sudo systemctl status smoothscroll

enable --now starts it immediately and on every boot. Logs, including the device-capture lines:

journalctl -u smoothscroll -f

Retuning friction or min_velocity later means editing /etc/smoothscroll.toml (not the copy in this repo) and restarting:

sudo systemctl restart smoothscroll

Stopping it

The daemon holds an exclusive grab on your trackball. If it ever misbehaves, killing it releases every grab immediately — the kernel drops EVIOCGRAB when the process's file descriptors close — and your devices go straight back to normal. There is no state to clean up.

sudo systemctl stop smoothscroll      # under systemd — use this one
sudo pkill -x smoothscroll            # foreground run, or no systemd

Under systemd, use systemctl stop. The unit sets Restart=always, so pkill only makes systemd start it again two seconds later. The nuclear version, which stops it and prevents the restart:

sudo systemctl kill -s SIGKILL smoothscroll; sudo systemctl stop smoothscroll

Worth keeping somewhere reachable from a TTY. All of these are safe to run when the daemon is not running.

To stop it starting at boot without uninstalling:

sudo systemctl disable --now smoothscroll

Uninstall

# 1. Stop and forget the service
sudo systemctl disable --now smoothscroll
sudo rm -f /etc/systemd/system/smoothscroll.service
sudo systemctl daemon-reload

# 2. Remove the binary and config
sudo rm -f /usr/local/bin/smoothscroll /etc/smoothscroll.toml

Then delete the hl.device({ name = "smoothscroll-virtual-pointer", ... }) block from ~/.config/hypr/input.lua and reload:

hyprctl reload

Leaving the block in place is harmless — Hyprland ignores config for a device that does not exist — but any pointer tuning you moved into it goes away with the daemon, so move sensitivity and natural_scroll back onto your physical device block at the same time.

Nothing else is touched: no udev rules, no kernel modules, no files outside the three paths above.

QMK side

The trigger key must send mouse button 6, not 4 or 5:

// keymap.c — newer QMK keycode alias
MS_BTN6
// older QMK (pre-2022 naming)
KC_MS_BTN6

Button 6 arrives as evdev BTN_FORWARD (277), which is what scroll_button defaults to. Avoid buttons 4 and 5: they land on BTN_SIDE (275) and BTN_EXTRA (276), which browsers bind to back/forward — a guaranteed conflict. Note that evdev's BTN_FORWARD/BTN_BACK names are misleading; no common application binds them.

Confirm your firmware actually exposes 8 buttons:

awk '/your device name/,/^$/' /proc/bus/input/devices

A KEY=ff0000 0 0 0 0 line means bits 272–279 are set — all eight mouse buttons. If you see 1f0000 instead, the descriptor stops at button 5 and you need a newer QMK.

Notes and limitations

  • Root is required. EVIOCGRAB on /dev/input/event* and writing to /dev/uinput both need it. The alternative — joining the input group — would let every process in your session read your keystrokes, which is worse. The bundled unit drops all capabilities and confines the daemon to those two device classes.
  • A quick tap passes through as a click. libinput forwards the button if it is pressed and released without motion. That is libinput's behaviour, not a bug here; it is also why the button stays usable.
  • Terminals still scroll by lines. foot, alacritty and ghostty quantize to text rows no matter what they are fed. Smoothness shows up in GTK, Qt, Electron and Firefox.
  • Grabbed devices are exclusive. List only pointer nodes. Grabbing a plain keyboard node would swallow your typing.
  • Hotplug is handled. /dev/input is watched with inotify; unplugging the trackball drops it and replugging re-captures it.
  • Signals are handled so the virtual button is always released on exit — a daemon killed mid-scroll must not leave a button stuck down. SIGKILL cannot be caught, but the kernel still drops the grab when the process dies, so the worst case is a momentarily stuck button, not a stuck trackball.
  • Omarchy Quattro reads Lua, not .conf. hyprland.lua requires hypr.input, so ~/.config/hypr/input.lua is authoritative and any input.conf still sitting in that directory is dead weight.

Tuning

Symptom Change
Coast too short lower friction toward 0.0005, or raise it toward 0.02 for longer
Slow tail feels draggy raise min_velocity
Flicks overshoot lower max_velocity
No coast at all you stopped moving before releasing; flick and release while still rolling
Scroll direction wrong natural_scroll in the hl.device block
Scroll speed scroll_factor in the hl.device block
Cursor speed sensitivity in the hl.device block

Everything in the first column of smoothscroll.toml needs a sudo systemctl restart smoothscroll; everything in the hl.device block needs a hyprctl reload.

Troubleshooting

Nothing scrolls, cursor still moves while the button is held. libinput never entered button-scroll mode, so the device block is not reaching Hyprland. On Omarchy Quattro this is almost always because the config went into ~/.config/hypr/input.conf, which Quattro does not read — it must be input.lua. Check hyprctl configerrors, then confirm the device is present:

hyprctl devices | grep -A4 smoothscroll

Nothing scrolls, and the button still does its normal thing (browser forward, etc). Same cause: nothing has claimed the button, so libinput passes it straight through.

--monitor shows no button at all. The firmware is not sending a mouse button on that key. Nothing downstream can help:

sudo smoothscroll --monitor

It is passive — no grab — so it is safe to run alongside the daemon or while working normally. It prints every button press with its evdev name and the QMK keycode that produces it.

Everything looks right in --debug but apps do not scroll. Check you are not testing in a terminal; foot, alacritty and ghostty quantize to text rows.

Prior art

Replaces LinuxScrollFix, which emitted REL_WHEEL and was therefore structurally limited to whole wheel detents. Inspired by Mac Mouse Fix.