Last active
June 5, 2026 11:05
-
-
Save bielarusajed/4f6f6c1d38f2c4e60987f0209e33f97e to your computer and use it in GitHub Desktop.
libinput plugin for touchpad / trackpad scrolling speed control. Vibe coded.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- Reduce Magic Trackpad two-finger scroll speed by scaling multitouch motion. | |
| -- The curve is { touchpad-coordinate-units-per-ms, multiplier }. | |
| -- Low speeds stay precise; fast swipes are slowed less. | |
| local SCROLL_SPEED_CURVE = { | |
| { 0.0, 0.25 }, | |
| { 1.0, 0.25 }, | |
| { 4.0, 0.40 }, | |
| { 10.0, 0.65 }, | |
| { 25.0, 0.90 }, | |
| } | |
| local APPLE_VENDOR_ID = 0x004c | |
| local MAGIC_TRACKPAD_BLUETOOTH_PRODUCT_ID = 0x0265 | |
| libinput:register({1}) | |
| local function round(value) | |
| if value >= 0 then | |
| return math.floor(value + 0.5) | |
| end | |
| return math.ceil(value - 0.5) | |
| end | |
| local function scroll_multiplier(speed) | |
| if speed <= SCROLL_SPEED_CURVE[1][1] then | |
| return SCROLL_SPEED_CURVE[1][2] | |
| end | |
| for i = 1, #SCROLL_SPEED_CURVE - 1 do | |
| local p1 = SCROLL_SPEED_CURVE[i] | |
| local p2 = SCROLL_SPEED_CURVE[i + 1] | |
| if speed >= p1[1] and speed <= p2[1] then | |
| local t = (speed - p1[1]) / (p2[1] - p1[1]) | |
| return p1[2] + t * (p2[2] - p1[2]) | |
| end | |
| end | |
| return SCROLL_SPEED_CURVE[#SCROLL_SPEED_CURVE][2] | |
| end | |
| local function slot_count(slots) | |
| local count = 0 | |
| for _, slot in pairs(slots) do | |
| if slot.active then | |
| count = count + 1 | |
| end | |
| end | |
| return count | |
| end | |
| local function reset_virtual_positions(slots) | |
| for _, slot in pairs(slots) do | |
| slot.x_virtual = slot.x | |
| slot.y_virtual = slot.y | |
| slot.x_initialized = false | |
| slot.y_initialized = false | |
| end | |
| end | |
| local function connect_scroll_scaler(device) | |
| local slots = {} | |
| local current_slot = 0 | |
| local scaling_active = false | |
| local last_timestamp = 0 | |
| device:connect("evdev-frame", function (_, frame, timestamp) | |
| local changed = false | |
| local dt_ms = 16.0 | |
| if last_timestamp ~= 0 then | |
| dt_ms = (timestamp - last_timestamp) / 1000.0 | |
| if dt_ms < 1.0 then | |
| dt_ms = 1.0 | |
| elseif dt_ms > 200.0 then | |
| dt_ms = 200.0 | |
| end | |
| end | |
| last_timestamp = timestamp | |
| for _, event in ipairs(frame) do | |
| if event.usage == evdev.ABS_MT_SLOT then | |
| current_slot = event.value | |
| slots[current_slot] = slots[current_slot] or {} | |
| elseif event.usage == evdev.ABS_MT_TRACKING_ID then | |
| local slot = slots[current_slot] or {} | |
| slots[current_slot] = slot | |
| if event.value == -1 then | |
| slot.active = false | |
| slot.x_initialized = false | |
| slot.y_initialized = false | |
| else | |
| slot.active = true | |
| slot.x_initialized = false | |
| slot.y_initialized = false | |
| end | |
| local fingers = slot_count(slots) | |
| local should_scale = fingers == 2 | |
| if scaling_active ~= should_scale then | |
| reset_virtual_positions(slots) | |
| scaling_active = should_scale | |
| end | |
| elseif event.usage == evdev.ABS_MT_POSITION_X or event.usage == evdev.ABS_MT_POSITION_Y then | |
| local slot = slots[current_slot] or {} | |
| slots[current_slot] = slot | |
| local axis = event.usage == evdev.ABS_MT_POSITION_X and "x" or "y" | |
| local initialized = axis .. "_initialized" | |
| if slot.active and slot[initialized] and scaling_active then | |
| local physical = slot[axis] | |
| local virtual = slot[axis .. "_virtual"] | |
| local delta = event.value - physical | |
| local speed = math.abs(delta) / dt_ms | |
| local scaled = virtual + delta * scroll_multiplier(speed) | |
| slot[axis] = event.value | |
| slot[axis .. "_virtual"] = scaled | |
| event.value = round(scaled) | |
| changed = true | |
| else | |
| slot[axis] = event.value | |
| slot[axis .. "_virtual"] = event.value | |
| slot[initialized] = true | |
| end | |
| end | |
| end | |
| if changed then | |
| return frame | |
| end | |
| return nil | |
| end) | |
| device:connect("device-removed", function (removed_device) | |
| removed_device:disconnect("evdev-frame") | |
| removed_device:disconnect("device-removed") | |
| end) | |
| end | |
| libinput:connect("new-evdev-device", function (device) | |
| local info = device:info() | |
| if info.vid ~= APPLE_VENDOR_ID or info.pid ~= MAGIC_TRACKPAD_BLUETOOTH_PRODUCT_ID then | |
| return | |
| end | |
| local usages = device:usages() | |
| if usages[evdev.ABS_MT_SLOT] | |
| and usages[evdev.ABS_MT_TRACKING_ID] | |
| and usages[evdev.ABS_MT_POSITION_X] | |
| and usages[evdev.ABS_MT_POSITION_Y] then | |
| connect_scroll_scaler(device) | |
| libinput:log_info("Magic Trackpad scroll speed curve enabled") | |
| end | |
| end) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Disclaimer: I'm not familiar with libinput, its plugin system, or Lua. I've just updated to Gnome 50, and since they added the support for the libinput plugins, I've asked ChatGPT to fix the scrolling speed for me. It gave me this plugin.
Some instructions for Gnome 50+:
~/.config/libinput/plugins/(create if it doesn't exist)To check the trackpad ID, use
libinput list-devices. For me it looks like this:And bluetooth:004c:0265 becomes this:
Libinput 1.30.0 or higher is required, as this is the version the plugin system was introduced. I guess if you want to apply the plugin globally, you put the file into
/etc/libinput/plugins/.If I understand correctly from the MR#4739, the
~/.config/libinput/plugins/directory is enabled by Gnome itself and can take no effect in other Wayland compositors, so check the docs for your environment.