# One Press, Two Clicks

Source: https://gofranz.com/blog/one-press-two-clicks/

Yesterday my MX Anywhere 2S [wouldn't reconnect](/blog/the-headphones-connected-the-mouse-didnt/). 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:

```python
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](https://wayland.freedesktop.org/libinput/doc/latest/button-debouncing.html) 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](https://gitlab.com/interception/linux/tools). `intercept` grabs the device exclusively, the filter transforms the byte stream, `uinput` re-emits it on a clone that libinput picks up instead:

```bash
$ 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:

```yaml
- 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](https://codeberg.org/gofranz/panther), so the config never gets typed into `/etc`:

```scheme
(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](https://github.com/franzos/mouse-debounce) if your switch is going the same way. Measure yours first.
