Skip to content

Mouse Button Lock parity with PowerToys (1.1.0) - #1

Open
owenpkent wants to merge 10 commits into
mainfrom
feature/mouse-button-lock-parity
Open

owenpkent wants to merge 10 commits into
mainfrom
feature/mouse-button-lock-parity

Conversation

@owenpkent

Copy link
Copy Markdown
Owner

Summary

Brings Linux Quick Drag to parity with the PowerToys Mouse Button Lock module (microsoft/PowerToys#49279). The per-button state machine is a line-by-line port of the PowerToys engine and carries the same unit tests and fuzzing, so the two ports behave the same wherever X11 allows it.

Version 1.1.0.

Behaviour

  • Lock the left, right or middle button independently. Left and right are on by default; middle is off. Linux has no built-in ClickLock, so the left button is on unlike PowerToys.
  • Press any button to release a lock. The locked button's own press is a tap-to-release; a different button releases the held button and then clicks normally (a chorded release, so no context menu opens).
  • Drag threshold (move_cancel_pixels, default 5 px): moving beyond it during the hold marks the gesture as a deliberate drag and the lock does not engage.
  • Hold duration default 1200 ms (was 500 ms), GUI range 200 to 2200 ms, hard limit 200 to 60000 ms for hand-edited files.
  • Context-menu dismissal after a same-button release of a locked right button, as in PowerToys. Best effort on X11 and switchable off (dismiss_context_menu).
  • Settings apply live to the running service. Disabling a locked button's setting releases it.

Architecture

  • src/engine.py: pure state machine, no X11 or GTK imports. Mirrors MouseButtonLockCore.h.
  • src/config.py: one validated loader shared by GUI and CLI. Wrong types fall back to defaults, numbers are clamped to the PowerToys limits, NaN and Infinity are rejected, invalid JSON never crashes the app, saves are atomic. The 1.0 threshold_ms key is migrated and a 1.0 file keeps left-button-only locking.
  • src/quickdrag.py: X11 adapter. Button events come from XInput slave devices with the XTEST and master devices filtered out, so our own injections never echo back as release taps (they do through core events). Lock engage re-injects an XTEST press because RECORD cannot suppress the physical release.
  • src/gui.py: single-instance Gtk.Application, per-button checkboxes, hold slider, drag threshold, live apply, and the worker is joined on close and on SIGINT/SIGTERM so a locked button is released before exit.

Fixes found along the way

  • Stop could leave the RECORD thread blocked until the next mouse event: the disable request now goes over a separate connection with a round trip.
  • A config.json with wrong types or out-of-range values no longer crashes the GUI at startup.
  • Closing the window with a button locked could leave it held.
  • The dpkg-buildpackage route shipped a launcher that could not find the modules. Both routes now install the same launcher.

Tests and fuzzing

Check Result
pytest 323 passed
Hypothesis stateful engine tests, 2000-example profile passed
Atheris engine harness, 5 min 616k runs, 0 crashes
Atheris config harness, 5 min 5.8M runs, 0 crashes
  • The 17 PowerToys engine tests are ported one to one with the same ticks and thresholds.
  • X11 adapter tests run against hand-built RECORD replies and a recording injector; a live smoke test runs only with a display and injects nothing.
  • CI runs the suite on Python 3.10 and 3.12, a short fuzz pass, and the .deb build.

Known limitations (X11)

  • RECORD observes but cannot suppress input, so the initial click of a locked right or middle button still reaches applications. Windows suppresses this; an evdev/uinput backend could too.
  • A hold duration shorter than the desktop's double-click time can make the lock register as a double click in some applications.

Manual testing still needed

The lock, tap-to-release and cross-button release paths were verified against fake wire data and in Xephyr, not with real hardware on a live desktop. Please test a real drag before merging. See docs/TESTING.md.

Owen Kent added 9 commits September 23, 2026 23:10
Port the PowerToys Mouse Button Lock state machine to a pure Python module
with no X11 or GTK dependency: per-button hold/lock state for left, right
and middle, a move-cancel dead zone, any-button-releases semantics with
tap-to-release and chorded cross-button release, and the context-menu
dismissal flag. Add a shared config loader that validates types, clamps
ranges to the PowerToys limits, rejects non-finite numbers, migrates the
1.0 threshold_ms key while keeping left-button-only behaviour, and saves
atomically.
Rewrite quickdrag.py as an adapter: XInput slave device events are taken
from RECORD with the XTEST and master devices filtered out so our own
injections never echo back as taps, core MotionNotify feeds the dead zone,
lock engage re-injects an XTEST press, and releases inject XTEST ups with
the right-button Escape dismissal deferred until the tap's release. Settings
apply live on the next input event. request_stop uses a separate connection
with a round trip so Stop no longer waits for the next mouse event. The CLI
gains per-button flags, --move-cancel, --config and clamped overrides.
Replace the single threshold spin button with per-button checkboxes, a
200-2200 ms hold slider, a drag threshold spin button and the Escape
dismissal option, all saved immediately and applied to the running service.
Run as a single Gtk.Application instance so launching the app while the
autostarted copy runs presents its window. Join the worker on close and on
SIGINT/SIGTERM so a locked button is released before exit, and reset the UI
when the service stops or errors.
Port the 17 PowerToys engine tests one to one, add Python-specific engine
tests, a Hypothesis stateful machine with engine invariants, config
validation tests including property tests, and X11 adapter tests driven by
hand-built RECORD replies and a recording injector. A live smoke test runs
only when an X display is available and injects nothing.
Mirror the PowerToys libFuzzer harness: byte-driven engine operations with
settings mutation, arbitrary ticks and coordinates, plus in-harness
invariants. A second target feeds arbitrary text and JSON-like structures
to the config parser. run_fuzz.sh runs both for a time budget.
Bump the version, update both .deb build routes to ship engine.py and
config.py through a shared launcher, drop debian/compat, install the
autostart entry from debian/rules, and replace the checked-in package.
Bring every document, docstring and help text in line with the shipped
code: the dismiss_context_menu setting, single-instance GUI, worker join on
exit, the sync-based stop, packaging routes and the real test count. Along
the way: run.py now checks the XInput extension the service needs, the
config fuzzer covers the new key, the CLI gains --no-debug, the stop
fallback path uses a round trip too, and the banner reports the dismissal
setting.
@socket-security

socket-security Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedhypothesis@​6.168.19810010010070
Addedpython-xlib@​0.339710010010070
Addedpytest@​9.1.187100100100100
Addedatheris@​3.1.099100100100100

View full report

The current atheris wheel fails at import on 3.10 (it needs dis.Positions
from 3.11), which took the canned-input replay tests down with it. Treat any
import failure as "atheris unavailable" so the pure-Python fallback is
used, and only require atheris on 3.11 or newer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant