Skip to content

Extended the Thumb button functionalities#227

Open
Joseph-Azzam wants to merge 1 commit into
TomBadash:masterfrom
Joseph-Azzam:master
Open

Extended the Thumb button functionalities#227
Joseph-Azzam wants to merge 1 commit into
TomBadash:masterfrom
Joseph-Azzam:master

Conversation

@Joseph-Azzam

@Joseph-Azzam Joseph-Azzam commented Jul 4, 2026

Copy link
Copy Markdown

tl;dr
-Added on Thumb Button Press and Release events
-Added ability to run custom CLI commands
-Added an option for the mouse to move while thumb button is pressed
P.S. I used AI for this for personal use, so if you want to use it, inspect it well.

Summary

Extends Mouser's handling of the MX Master-style gesture/thumb button with three related improvements:

  1. Separate press and release actions for the gesture button, instead of only firing on release.
  2. Hold-to-press semantics — when a press action is a keyboard shortcut or mouse button, it's held down for as long as the physical button stays pressed (instead of a tap), so it works as a real modifier (e.g. hold-to-Ctrl).
  3. Run Terminal/CLI Command as a new action type, selectable from every action dropdown (including the gesture button and other mouse buttons).

Along the way, this also fixes a real bug where the gesture button's cursor-freeze behavior could persist on the device after Mouser closed, and adds a "lock cursor while swiping" toggle with synthetic movement injection as a workaround for the freeze while swipe detection is active.

1. Thumb button press + release detection

Previously the gesture/thumb button only exposed a single "tap action" mapping, fired on release. This PR:

  • Renames the existing mapping key from gesturegesture_release (UI label: "TAP RELEASE ACTION").
  • Adds a new gesture_press mapping (UI label: "TAP PRESS ACTION"), wired to two new internal events (gesture_down / gesture_up) fired directly from each platform's HID++ gesture button handler (mouse_hook_windows.py, mouse_hook_macos.py, mouse_hook_linux.py).
  • Named the new internal events gesture_down/gesture_up (not press/release) deliberately, since the codebase already uses up/down/left/right for swipe directions — gesture_press/gesture_release stay reserved for the mapping keys, gesture_down/gesture_up for the physical button state, and swipe direction names are untouched.
  • Adds a config migration to v9 → v10 (core/config.py::_migrate) that renames any existing gesture mapping to gesture_release per profile and seeds gesture_press: "none".
  • Propagates the gesture_release/gesture_press key rename through the device/button catalogs (core/logi_devices.py, core/logi_device_catalog.py, core/device_layouts.py) so per-device supported-button lists and hotspot layouts stay consistent.
  • Updates MousePage.qml to show both dropdowns, plus a combined summary label (e.g. Press: Ctrl | Tap: Copy | Swipes configured).

2. Hold-to-press semantics for gesture_press

If a user maps gesture_press to a plain keyboard shortcut or a mouse click, it now behaves like actually holding that key/button down, not clicking it once:

  • core/key_simulator.py adds send_key_down(keys) / send_key_up(keys) (all three platforms — Windows SendInput, macOS CGEventCreateKeyboardEvent, Linux uinput), plus higher-level helpers:
    • _resolve_holdable_keys(action_id) — returns the underlying key list for an action if it's a plain shortcut/custom combo, or None for actions that can't sensibly be "held" (multi-tap zoom, screenshots, run-command, media-key sequences, browser-nav phased taps, etc).
    • is_holdable_key_action(action_id), press_action_down(action_id), press_action_up(action_id).
  • core/engine.py::_setup_hooks() now checks is_holdable_key_action() when wiring up any button event pair that has a matching _down/_up event (this already applied to mouse-button actions; it's now extended to holdable keyboard actions). Registers _make_key_down_handler / _make_key_up_handler, mirroring the existing mouse-button-hold pattern, including a 20s safety auto-release timer in case the _up event is ever missed (e.g. device disconnects mid-hold).
  • Non-holdable actions (run command, screenshot, multi-tap, etc.) continue to fire immediately on press, same as before.

3. Run Terminal/CLI Command action

Adds a new action type, run:<command line>, available from every action dropdown (gesture press/release, swipes, back/forward, scroll-tilt, other mouse buttons):

  • core/key_simulator.py: parse_run_command() splits the command line with shlex (POSIX mode on macOS/Linux, non-POSIX + quote-stripping on Windows so backslash paths survive). execute_run_command() always launches with subprocess.Popen(argv, shell=False, ...) on a daemon worker thread, stdio redirected to DEVNULL, CREATE_NO_WINDOW on Windows — so a slow-starting process can't block the mouse hook, and shell metacharacters (|, &&, >, ...) in the text are never interpreted, only passed as literal argv.
  • ui/backend.py: adds the __run_command__ sentinel to the action list, run_command_label()/runCommandTextFor() for display, and runCommandValidationErrorInfo() for inline validation.
  • New ui/qml/RunCommandDialog.qml — a small modal for entering/editing the command line, wired into every action-picker combo box and button chip in MousePage.qml.
  • New locale strings for the dialog (run_command.*) in ui/locale_manager.py (en, zh-Hans, zh-Hant).

4. "Lock cursor position while swiping" + root-cause fix for the freeze

This part went through a couple of iterations after testing surfaced a real bug — documenting the full story since it affects how this should be reviewed:

The setting: a new gesture_lock_cursor config flag (default True, matches prior behavior) and QML switch. When unchecked, and swipe actions need raw sensor data, Mouser synthesizes cursor movement (inject_mouse_move()SendInput/CGEventCreateMouseEvent/uinput EV_REL per platform) from the same raw XY deltas already used for swipe detection, since the gesture button suppresses normal cursor movement at the firmware level while held, independent of any HID++ divert request.

Root cause found: HidGestureListener.stop() (core/hid_gesture.py) closed the HID device handle directly without ever calling _undivert(). Since _undivert() only ran inside the listener thread's own loop-exit cleanup, and stop() never gave it a chance to run, whatever divert/raw-XY state was last active on the device stayed in effect for the rest of that wireless session — even after Mouser fully quit. This is what made the freeze look hardware-inherent during earlier testing (a stale raw-XY divert from a previous session was still active), and also what made the "still moving when the checkbox is checked" regression possible when the last session had turned raw-XY off. Fixed by calling _undivert() (best-effort, exception-swallowed) before closing the device in stop().

Also fixed: toggling the setting used to call force_reconnect(), which drops and fully re-establishes the whole HID++ connection — visible to the user as the mouse disappearing for a few seconds. Replaced with an in-place re-divert: set_raw_xy_wanted() now just flags _redivert_requested, which the listener thread's existing pending-command loop (same pattern as _pending_dpi/_pending_smart_shift) picks up and applies via a single SetCidReporting HID++ write — no reconnect needed.

  • core/mouse_hook_base.py adds _wants_gesture_raw_xy() (raw XY needed if swipes are configured or the user opted out of locking) and _wants_gesture_movement_injection() (not gesture_lock_cursor), plus _sync_gesture_raw_xy() to push live changes to an already-connected listener.
  • ui/backend.py adds gestureLockCursor property / setGestureLockCursor() slot, wired through engine.reload_mappings().

Config migration

DEFAULT_CONFIG["version"] bumped 9 → 10. Migration renames gesturegesture_release per profile mapping, seeds gesture_press: "none", and defaults settings.gesture_lock_cursor = True. Existing users keep their current tap-release mapping; nothing changes in default behavior unless they explicitly configure the new options.

Testing

  • Added/updated unit tests across tests/test_config.py, tests/test_engine.py, tests/test_hid_gesture.py, tests/test_key_simulator.py, tests/test_mouse_hook.py, tests/test_backend.py, tests/test_logi_devices.py, tests/test_smart_shift.py covering: config migration, holdable-key resolution/press/release on all three platforms, run-command parsing/execution/validation, gesture_down/gesture_up dispatch per platform, raw-XY want/redivert logic, and stop() calling _undivert() before close.
  • Full suite: python -m unittest discover -s tests -q — all relevant tests pass; a handful of pre-existing failures remain that are unrelated environment mismatches (Linux/macOS-specific screenshot/startup helpers failing when run on Windows), not caused by this change.

Manual test plan

  • Map gesture_press to a plain shortcut (e.g. Ctrl) and verify it acts as a held modifier for as long as the thumb button is held.
  • Map gesture_release to an action and confirm it still fires only on release, as before.
  • Add a "Run Command" action to a button and confirm it launches without a visible console window and without shell interpretation of |/&&/etc.
  • With no swipe actions configured, hold the gesture button and confirm the cursor moves normally (native, no injection needed).
  • With swipe actions configured, toggle "Lock cursor position while swiping" off and confirm the cursor keeps moving (synthesized) during a hold, with no multi-second "device not detected" blip when toggling.
  • Fully quit Mouser and confirm the mouse's native button behavior is restored (no lingering divert state).

Disclosure

Built with AI assistance for personal use — please review carefully, especially the HID++ divert/undivert flag handling in core/hid_gesture.py, before merging.

-Added on Thumb Button Press and Release events
-Added ability to run custom CLI commands
-Added an option for the mouse to move while thumb button is pressed
P.S. I used AI for this for personal use, so if you want to use it, inspect it well.
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