Skip to content

Repository files navigation

scrollcurve

macOS-style touchpad scroll and pointer acceleration for GNOME/Wayland, without patching, forking, or replacing anything.

Linux touchpad scrolling is a flat multiply — no acceleration at all — which is why it feels twitchy for small movements next to macOS, which damps slow deliberate motion and amplifies flicks. libinput has shipped the API to fix this since 1.23. No compositor calls it. This is a ~700-line LD_PRELOAD shim that makes the call.

Quick start

git clone https://github.com/timwessman/scrollcurve && cd scrollcurve
make            # needs gcc + make + python3. No libinput-dev.
make check      # end-to-end test: no root, no hardware, no compositor
./install.sh    # per-user; no root, nothing outside $HOME

Then log out and back in. That is the whole install.

./install.sh --revert removes it. Nothing is touched outside your home directory, and the GDM login screen is never affected, so you cannot lock yourself out — see Recovery.

Requirements

Compositor GNOME Shell on Wayland (the drop-in targets org.gnome.Shell@.service)
libinput 1.23+ for the API; constants verified against 1.31 (see Version coupling)
Build gcc, make, python3. libinput-tools optional, for make test
Hardware any libinput touchpad — non-touchpads are skipped automatically

Developed on a MacBookPro12,1 (bcm5974), but nothing in it is Apple-specific: the curves are authored in mm/s, and libinput normalises every touchpad to a 1000-dpi-equivalent axis before the filter runs.

What you get by default

install.sh writes the macos preset — the curve set tuned by feel over four iterations against a real MacBook Pro trackpad sitting next to the Linux machine. You will feel the change at next login. If you would rather start from a guaranteed no-op and bend it yourself:

./gen-curves.py --preset neutral > ~/.config/scrollcurve.conf

neutral reproduces stock libinput behaviour exactly, and is the only configuration you can verify by feel — if it feels different, something is wrong. It is worth installing neutral first for one login just to prove the path works, then switching to macos.

Why this exists

libinput has supported user-defined acceleration curves since 1.23: LIBINPUT_CONFIG_ACCEL_PROFILE_CUSTOM, with a dedicated LIBINPUT_ACCEL_TYPE_SCROLL function documented as "supported by mouse and touchpad". The engine is already installed here — libinput.so.10 exports libinput_config_accel_set_points@@LIBINPUT_1.23.

Nobody calls it. GNOME's accel-profile schema knows only default|flat|adaptive, and libmutter contains no reference to set_points. So the feature ships and sits unused.

Meanwhile the default touchpad scroll path is literally constant (filter-touchpad.c:touchpad_scroll_filtertouchpad_constant_filter): scroll distance is a fixed multiple of finger travel, with no acceleration at all. That flat response is why Linux touchpad scrolling feels oversensitive for small movements next to macOS, which damps slow finger motion and amplifies flicks.

This shim sits between the compositor and libinput and makes the config call mutter never makes. Public, stable API only.

How it works

Two interposed symbols:

  • libinput_get_event() — applies the curve when a matching device is announced (LIBINPUT_EVENT_DEVICE_ADDED). This is also what makes the shim testable with no compositor at all.
  • libinput_device_config_accel_set_profile() — the load-bearing hook. mutter asserts its own gsettings accel-profile after draining the device-added event, and would otherwise overwrite us moments later, and again on every settings change. Intercepting the call makes mutter's own attempt the trigger, so it can never win the race.
  • libinput_device_config_accel_set_speed() — the speed slider. See below.

The speed slider, reclaimed

libinput's custom filter accepts set_speed() and then throws the value away. That is why GNOME Settings → Touchpad speed goes dead the moment a custom profile is selected — the slider still moves, mutter still makes the call, and libinput deliberately does nothing with it.

So the call is a free, fully-plumbed knob with no meaning attached. The shim gives it one: speed-ref in the config records the slider position the authored gains assume, and any other position rescales the curve by libinput's own slider mapping (speed_factor(), pow(s+1, 2.38)*0.95+0.05) relative to that reference. At speed-ref you get the gains exactly as written; either side, the knob feels exactly as it always did. speed-affects picks which curves it scales (both, motion, scroll, none), and deleting speed-ref makes the slider inert again.

The side effect is the bigger prize. Applying re-reads the config, so moving the slider is a live reload of the whole curve. libinput otherwise only reads a curve at device-add, which meant every tuning change cost a log-out and log-in — by a distance the most tedious thing about this project. Now: regenerate the config, nudge the slider, feel the result.

Write the config atomically (> tmp && mv). A torn read fails validation, and the shim's response to that is to go inert.

Failure is always inert: a missing, unparseable or unvalidated config makes the shim call straight through, and the touchpad behaves exactly as it does today. This code runs inside gnome-shell; being useless is fine, crashing is not.

Units, and the one trap

The curve's x-axis is libinput units/ms. Touchpad deltas are normalised to a 1000-dpi-equivalent device before the filter (tp_scale_to_xaxis), so:

mm/s = (units/ms) × 25.4

Work in mm/s. It is physical, and it is the only unit that means the same thing on macOS and Linux — same slab of glass either way.

The trap: libinput's y-values are output speed, not gain. The applied factor is speed_out / speed_in (filter-custom.c:191). A constant gain g is therefore the line y = g·x through the origin, not a row of constant y values. Get this wrong and a "flat" curve becomes a fixed-speed curve where slow movement is wildly amplified.

scrollcurve.conf sidesteps it: author *-gains and the shim converts (points[i] = gain[i] · i · step). Raw *-points are accepted if you want them.

Beyond the last point libinput extrapolates linearly through the last two points, so ending on two equal gains clamps the gain for arbitrarily fast flicks; ending on a rising pair lets it grow unbounded.

The pointer curve is not optional

Selecting PROFILE_CUSTOM swaps the device's entire motion filter (evdev.c:1153), and any accel type left undefined falls back to custom.fallback, which defaults to a flat f(x)=x. Shipping a scroll curve alone does not leave the pointer alone — it silently destroys pointer acceleration. The shim refuses to apply unless both curves are present.

gen-curves.py therefore reconstructs libinput's adaptive profile (touchpad_accel_profile_linear, threshold 130 mm/s, baseline 0.9, TP_MAGIC_SLOWDOWN 0.2968, scaled by your gsettings speed) so the default motion curve tracks what you already have.

It cannot track it perfectly. adaptive has a deceleration knee at 7 mm/s and a plateau at 520 mm/s — a 74× span — and 64 uniform points cannot resolve both ends. Run ./gen-curves.py --analyse for the tradeoff table. The good news is that 7–130 mm/s, where nearly all pointer movement lives, is exact at any step, because adaptive is a flat baseline through that whole range. The error is concentrated in fine positioning and hard flicks.

Use

make                      # build (no libinput-dev needed; ABI is vendored)
make check                # stub-libinput test; no root, no hardware
make test                 # exercise it under `libinput debug-events`
sudo ./scrollcurve-probe --info    # geometry + the neutral config
sudo ./scrollcurve-probe --sweep   # measure strokes in mm/s and units/ms
./install.sh              # install, scoped to the GNOME Shell user unit
./install.sh --revert     # remove

A new build of the .so needs a log out and back in. A config change does not — write the file, then nudge the touchpad speed slider to reload it.

./gen-curves.py --preset macos     # the tuned curve set
./gen-curves.py --preset neutral   # stock behaviour, exactly

Presets are just defaults; any explicit flag overrides them, so --preset macos --scroll-scale 0.2 is the preset with scroll turned down.

Which touchpad it applies to

By default, all of them — the config carries no match- lines, and the shim skips any device that is not a touchpad (it tests libinput_device_config_tap_get_finger_count() > 0; mice report 0). This matters because the curves are built on TP_MAGIC_SLOWDOWN, a touchpad-only constant, so aiming them at a mouse would produce nonsense.

With two touchpads, narrow it down:

./gen-curves.py --preset macos --match auto     # the one udev reports
./gen-curves.py --preset macos --match bcm5974  # by name substring

Shape and volume are separate knobs, deliberately. --lo/--hi (scroll) and --m-lo/--m-hi (pointer) bend the curve — how much slow movement is damped relative to fast. --scroll-scale/--motion-scale set the overall level, and so does the speed slider at runtime. Tuning is much easier when "I want the same feel but slower" is one knob and not a rebalancing act.

# scroll shape as-is, four times slower overall
./gen-curves.py --shape macos --lo 0.45 --hi 2.2 --scroll-scale 0.25

# pointer: fine positioning damped, full-screen sweeps untouched
./gen-curves.py --motion-shape macos --m-lo 0.6 --m-hi 1.0

--m-lo 1.0 --m-hi 1.0 is exactly libinput's adaptive, because the motion shape multiplies adaptive rather than replacing it.

Calibrating against macOS

--sweep reports each stroke in mm/s. Do the same on macOS — public NSTouch.normalizedPosition for finger travel, a CGEventTap for the resulting pointer/scroll deltas — and you can fit gain against a shared x-axis. Three things will ruin the data if you skip them:

  1. Measure totals per stroke, not instantaneous rates. Both signals are noisy and macOS applies its own smoothing and prediction. One steady sweep gives one clean (mean speed, gain) sample; differentiating per event does not.
  2. Discard momentum. Reject any scroll event with kCGScrollWheelEventMomentumPhase != 0, or you will fit inertia as if it were finger travel.
  3. Record the tracking-speed slider. macOS pointer acceleration is selected by it, so a measurement without it is unreproducible.

Note the ceiling: even a perfect curve only buys you the acceleration half of macOS feel. Momentum and rubber-band come from GTK and the application, not from libinput, and no amount of curve fitting reaches them.

Recovery

The drop-in is scoped to the GNOME Shell user unit. GDM's greeter runs its own shell as the gdm user, so the login screen is never affected. If a session fails to start: Ctrl+Alt+F3, log in on the TTY, run ./install.sh --revert, log back in.

SCROLLCURVE_DISABLE=1 bypasses the shim without uninstalling.

Version coupling

gen-curves.py replicates three constants from libinput's touchpad filter — TP_MAGIC_SLOWDOWN (0.2968), the 130 mm/s THRESHOLD, and the 0.9 baseline. These are internal to libinput, not API. If a future release changes them, this keeps emitting numbers that quietly no longer mean what they say, and the first casualty is --preset neutral silently ceasing to be neutral.

gen-curves.py warns on stderr when the installed libinput is not the tested version. The real check is by hand: install --preset neutral, log in, and confirm the touchpad feels identical to stock. If it does not, the constants moved and TESTED_LIBINPUT at the top of the script needs revisiting.

Similarly, if the touchpad ever feels stock again after an upgrade, that is the shim failing to load rather than a tuning problem. Check:

grep -z LD_PRELOAD /proc/$(pgrep -x gnome-shell)/environ

then set SCROLLCURVE_DEBUG=1 in the drop-in and re-login to see whether it is loading but failing to apply. SCROLLCURVE_DEBUG=1 logs to stderr, which systemd captures:

journalctl --user -u "org.gnome.Shell@*" -b | grep scrollcurve

Files

scrollcurve.c       the shim
libinput-shim.h     vendored libinput ABI (so no libinput-dev dependency)
probe.c             device geometry + stroke measurement
gen-curves.py       curve authoring and grid-error analysis
install.sh          install/revert, backs up what it touches
test/               stub libinput; exercises the shim with no root
REGENERATE.sh       write the accepted curves for this machine

No .conf files ship. A config records the machine it was generated on — the touchpad it matches and the speed-slider position its gains assume — so a checked-in one would apply to the author's laptop and silently no-op on yours. Generate your own; that is what --preset is for.

./gen-curves.py --preset macos   > ~/.config/scrollcurve.conf   # tuned
./gen-curves.py --preset neutral > ~/.config/scrollcurve.conf   # stock

How the macos preset was derived

Not fitted to measurements — tuned by feel over four iterations, with a MacBook Pro running macOS open next to the Linux machine for comparison. What each round changed, in case you want to move in the same directions:

  1. Scroll shape. --lo 0.45 --hi 2.20 over a 10→400 mm/s log ramp. The nonlinearity was right immediately; the overall level was ~4x too fast.
  2. Scroll level, split from shape. --scroll-scale 0.25. This is why level and shape are separate flags: "same feel, but slower" has to be one knob, or every adjustment becomes a rebalancing act. Reclaiming the speed slider happened here too, for the same reason.
  3. Pointer high end. A flick landed the cursor short of the screen edge. The cause was not a shape parameter — the motion table's 64 points at --motion-step 0.12 span only 0..192 mm/s, and a real flick is 300-500, so it ran off the end into libinput's linear extrapolation and delivered 0.55x of the intended gain at 400 mm/s. --motion-step 0.28 (0..448 mm/s) fixed it.
  4. Pointer high end, trimmed 10%. --m-hi 0.90 — but paired with --m-v-hi 130, because --m-hi alone tilts the whole ramp and would have dragged the mid-range down with it. Pulling the upper bound in to 130 mm/s (adaptive's own threshold, where libinput's built-in acceleration takes over) confines the change to the flick.

Two lessons that generalise if you tune your own: the lo/hi flags are tilts, not end-stops — to change one end without disturbing the middle, move the matching v- bound so the ramp saturates outside the range you want left alone. And check what speed range your grid actually covers (step x 63 x 25.4 mm/s) before blaming the shape.

License

MIT — see LICENSE. The replicated libinput constants and filter maths come from libinput, which is also MIT.

About

macOS-style touchpad scroll and pointer acceleration for GNOME/Wayland, via libinput's shipped-but-uncalled custom curve API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages