Skip to content

Franz Franz

goFranz // personal journal all writing
Tools · linux

One Press, Two Clicks

The left switch on my mouse wore out and started registering one click as two. libinput debounces buttons already, but only over 12-25ms and with no knob - so I measured the bounce and wrote a filter that sits below it.

Yesterday my MX Anywhere 2S wouldn’t reconnect. Today it clicks twice.

A soft press lands as two clicks, or drops a selection halfway through a drag. Press it hard and it behaves. That’s a worn microswitch: the contact bounces open for a few tens of milliseconds right after it closes, and everything upstream reads the bounce as a real release.

libinput debounces buttons already, but only over a 12-25ms window it doesn’t expose. Mine bounces for longer, so the fix has to sit below libinput on the raw evdev stream.

Measure first

libinput’s own debouncing hides the fault, so read the evdev node directly. Being in the input group is enough; you don’t need root to look:

with open("/dev/input/event15", "rb") as device:
    seconds, microseconds, eventType, code, value = struct.unpack("llHHi", device.read(24))

A minute of normal clicking gave me this:

      1.228s  LEFT   press      1123.9 ms
      1.320s  LEFT   release      92.1 ms
      4.280s  LEFT   press      1256.0 ms
      4.340s  LEFT   release      60.0 ms   <-- bounce
      4.384s  LEFT   press        44.0 ms   <-- and back
      5.380s  LEFT   release     996.1 ms

That second click is one physical press: closed, opened again 60ms later, closed 44ms after that, then held for a second while I dragged. Depending on what’s listening, that’s two clicks or an interrupted drag.

What makes it fixable is that the bands don’t overlap:

  press to release gap before the next press
bounce 36-60 ms 44-52 ms
real click 92-120 ms 776 ms and up

A bounce always let go inside 60ms; a deliberate click always held for at least 92ms. That’s the whole filter.

Why not libinput

Its window is 12ms for a plain bounce, 25ms once a button has been flagged as bouncy, and the documentation is upfront that there’s no knob for it. Sensible default - a configurable debounce is a footgun aimed squarely at people who’d rather turn a dial than replace a switch.

The rule

A release arriving within --short-ms of its press is suspect, so the filter withholds it for --hold-ms. Press the button again inside that window and the release gets dropped along with the new press: as far as anything upstream is concerned, the button never came up. Otherwise the release goes out late.

Only short presses get withheld, so an ordinary 100ms click is never delayed. And since a dropped selection and a spurious double-click are the same fault - a phantom release - swallowing it fixes the drag case too.

The pipeline

Below libinput means evdev, and that means interception-tools. intercept grabs the device exclusively, the filter transforms the byte stream, uinput re-emits it on a clone that libinput picks up instead:

$ intercept -g /dev/input/event15 | mouse-debounce | uinput -d /dev/input/event15

Try that the first time on a laptop with a working touchpad. While the device is grabbed, the only pointer you have is the one coming out of the pipe.

It’s 255 lines of C: read, poll, write, and input_event out of <linux/input.h>. No allocation, no ownership graph, and input_event has time_t fields that C sizes straight off the header - 64-bit on x86-64, 32-bit on armv7.

Testing

The filter is timer-driven, so there’s nothing meaningful to unit test; the behaviour is “did the next press arrive within 70ms”. The suite spawns the real binary, writes real input_event structs down the pipe at real wall-clock spacing, and counts the clicks that come out:

case                                         clicks  want
recorded: bounce during a drag                    1     1
recorded: bounce turning one click into two       1     1
deliberate double-click, 80 ms gap                2     2
undebounced button is left alone                  2     2

The recorded cases are traces off my own mouse. Scheduling can stretch a 60ms bounce past the hold window, so each case gets three attempts before it counts as a failure. It runs in the Guix build container too, so the package won’t build if the timing behaviour has drifted.

Wiring it up

udevmon keeps the job on the device across reconnects. This one’s Bluetooth, so it gets a new event node every time it wakes up:

- JOB: "intercept -g $DEVNODE | mouse-debounce --hold-ms 90 --short-ms 80 | uinput -d $DEVNODE"
  DEVICE:
    NAME: "MX Anywhere 2S Mouse"
    EVENTS:
      EV_KEY: [BTN_LEFT]

NAME is a full-match regex, not a substring - udevmon uses std::regex with regex_match, defaulting to .*. The EVENTS clause is what pins the job to the right node: a Logitech mouse presents several under sibling names.

On Guix that’s a package and a service in my channel, so the config never gets typed into /etc:

(service px-mouse-debounce-service-type
         (px-mouse-debounce-configuration
          (device-name "MX Anywhere 2S Mouse")
          (hold-ms 90)
          (short-ms 80)))

The service generates the YAML above with store paths baked into the job line, so there’s no PATH to get wrong inside udevmon’s sh -c.

Thresholds

I started at --hold-ms 70, because the longest bounce gap I’d captured was 52ms. It still slipped, within an hour of real use: sixty seconds of deliberate clicking catches the typical bounce, not the tail. I’m on 90 now.

Raising it is cheap. Sweeping the suite:

case                                            50    70    90   110   want
recorded: bounce then a long hold                2     1     1     1   1
deliberate double-click, 80 ms gap               2     2     2     2   2

Nothing breaks at 90, or at 110. The 80ms double-click survives both because --short-ms gates the whole mechanism: that trace’s first click is 100ms long, so its release was never withheld in the first place. --hold-ms only bites when a double-click has both a short first click and a short gap. 50 is where it actually breaks and lets a 52ms bounce through.

Limits

Two genuine clicks, each shorter than --short-ms and less than --hold-ms apart, are the same waveform as a bounce, and the filter will merge them. With my numbers that’s two sub-80ms clicks less than 90ms apart. I can’t produce that deliberately; someone faster might.

And this is a workaround on a switch that’s mechanically failing. The bounce window widens as it wears - I’ve already moved once - and eventually it’ll overlap a real double-click, at which point no threshold separates them. The durable fix is a soldering iron and a new Omron - or a new mouse.

The code is at github.com/franzos/mouse-debounce if your switch is going the same way. Measure yours first.