{ ... }: # macOS trackpad pointer-acceleration curve, transplanted onto libinput. # # WHERE THE CURVE COMES FROM # # macOS accelerates one pointer report as (IOHIDFamily, IOHIDPointerAccelerator): # # velocity = floor(hypot(dx, dy)) * rate_multiplier # mult = curve(velocity / (resolution / 67)) * (96/67) / velocity # # where curve() is a quartic up to TangentSpeedLinear, its tangent line up to # TangentSpeedParabolicRoot, and the square root of a line beyond that. # # The coefficients below were recovered by fitting that form to ~2200 pointer # reports captured from a real Apple trackpad (see ~/Downloads/code/rev), using # the cursor's 1/256-point position deltas as ground truth rather than the # integer CGEvent delta, which is rounded and cannot resolve better than +/-1: # # GainLinear 0.961648 TangentSpeedLinear 7.090 # GainParabolic 0.829395 TangentSpeedParabolicRoot 17.978 # GainCubic 0.122907 resolution 400 dpi @ 120 Hz # GainQuartic 0 tracking index ~0.6875 # # The fit reproduces every measured velocity bin (v = 1..48 device units per # report) to an rms of 2.4e-4 in gain. TangentSpeedParabolicRoot is the one # value NOT measured -- the captures top out at 14.4 in/s, well below the # roll-off -- so it is taken from the reported 32.2 in/s roll-off. It is # corroborated: it puts the factor peak at 38.0 in/s against a reported ~40. # # TRANSLATION TO LIBINPUT # # libinput's custom profile has the same shape as Apple's algorithm -- both map # an input speed to an output speed and use the ratio as the factor -- so the # curve transplants directly. Only the units differ. libinput's x axis is # *device units per millisecond* at the touchpad's own resolution (verified: # tp_filter_motion passes x through raw, scaling only y for axis aspect), so # the points below are specific to a 609.6 dpi (24 units/mm) touchpad. # # !! These numbers are tied to that resolution. magician's PIXA3854 is # !! 609.6 dpi. A touchpad with a different resolution needs the table # !! regenerated, or the curve will be wrong by the ratio of resolutions. # !! Check with: libinput list-devices, or EVIOCGABS on the event node. # !! To scope this to one machine instead, set services.libinput.touchpad.dev. # # WHAT DOES NOT TRANSFER # # Apple's floor() on velocity pins the gain flat below one device unit per # report (< 0.30 in/s) and then steps it up 12% at exactly |d| = 1. libinput's # f() is continuous and its points are uniformly spaced, so that discontinuity # cannot be represented. What we get for free: f() runs linearly from the origin # to the first knot, so the factor is constant across that first segment. The # step below places the first knot at 0.3175 in/s -- essentially exactly Apple's # 0.30 in/s plateau edge -- so the plateau's *width* is reproduced. Its value # lands ~6% high, because continuity forbids the step. # # RANGE # # 64 points is libinput's hard maximum (libinput_config_accel_set_points rejects # more), which forces a choice: uniform spacing means covering more speed costs # low-speed resolution. 20 in/s is the sweet spot rather than a compromise -- # past the last knot libinput extrapolates the final segment linearly, and # Apple's curve is genuinely a straight line from 12.7 to 32.2 in/s, so the # extrapolation is *exact* out to 30 in/s. Error only appears above the # roll-off: +2.7% at 40 in/s, +16% at 60 in/s. Those are flick speeds where # the pointer is being thrown across the screen anyway. # # Measured against the capture's own speed distribution: 0.22% weighted rms # error, worst case 1.7% below 15 in/s. let # Overall speed trim. The curve's shape is independent of this; it scales # cursor travel per finger inch uniformly. 1.0 maps one macOS point to one # X11 pixel. Raise it if the pointer feels sluggish on a HiDPI screen that # X is driving unscaled -- a macOS point is a *logical* unit, so an unscaled # HiDPI X session covers less physical distance per point than macOS does. gain = 1.0; # x = 0, step, 2*step, ... in touchpad device units per millisecond. # step = 20 in/s / 63 intervals, at 609.6 dpi. step = 0.193524; # f(x), the accelerated speed. libinput uses f(x)/x as the factor. points = [ 0.00000 0.03303 0.07349 0.12141 0.17680 0.23966 0.31000 0.38784 0.47319 0.56605 0.66644 0.77437 0.88985 1.01289 1.14350 1.28169 1.42748 1.58086 1.74186 1.91048 2.08674 2.27064 2.46220 2.66142 2.86832 3.08291 3.30520 3.53520 3.77291 4.01836 4.27155 4.53249 4.80119 5.07767 5.36193 5.65398 5.95384 6.26151 6.57702 6.90035 7.23154 7.56666 7.90177 8.23689 8.57201 8.90712 9.24224 9.57736 9.91247 10.24759 10.58270 10.91782 11.25294 11.58805 11.92317 12.25829 12.59340 12.92852 13.26364 13.59875 13.93387 14.26898 14.60410 14.93922 ]; in { # services.libinput.enable defaults to services.xserver.enable, so this file # is inert until X is turned on. services.libinput.touchpad = { accelProfile = "custom"; accelStepMotion = step; accelPointsMotion = map (p: p * gain) points; # Deliberately not setting accelSpeed: libinput ignores the speed setting # entirely under the custom profile (custom_accelerator_set_speed only # range-checks its argument and returns). The curve is the whole story. }; }