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.
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 $HOMEThen 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.
| 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.
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.confneutral 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.
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_filter → touchpad_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.
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 gsettingsaccel-profileafter 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.
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.
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.
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.
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.
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.
--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:
- 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.
- Discard momentum. Reject any scroll event with
kCGScrollWheelEventMomentumPhase != 0, or you will fit inertia as if it were finger travel. - 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.
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.
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
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
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:
- Scroll shape.
--lo 0.45 --hi 2.20over a 10→400 mm/s log ramp. The nonlinearity was right immediately; the overall level was ~4x too fast. - 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. - 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.12span 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. - Pointer high end, trimmed 10%.
--m-hi 0.90— but paired with--m-v-hi 130, because--m-hialone 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.
MIT — see LICENSE. The replicated libinput constants and filter maths come from libinput, which is also MIT.