Skip to content

feat(eventtap): let a mode bind the physical mouse buttons - #1418

Open
mitchellngoodman wants to merge 1 commit into
y3owk1n:mainfrom
mitchellngoodman:feat/mouse-button-bindings
Open

feat(eventtap): let a mode bind the physical mouse buttons#1418
mitchellngoodman wants to merge 1 commit into
y3owk1n:mainfrom
mitchellngoodman:feat/mouse-button-bindings

Conversation

@mitchellngoodman

@mitchellngoodman mitchellngoodman commented Aug 9, 2026

Copy link
Copy Markdown

Description

This PR adds the physical mouse buttons to the key vocabulary, so a mode can bind them the way it binds a key. Until now the mode event tap watched keyboard events only, so a click from a real mouse — or from a keyboard key that sends one — was invisible to the active mode: the click landed, the overlay stayed up, and dismissing it took a second keystroke.

A mode now observes a button press without consuming it, so the binding runs in addition to the click reaching the application underneath. Clicks Neru performs itself are marked and ignored, so a binding cannot recurse into its own action. Modifiers are deliberately not encoded into the name — a modified click reports the button alone — because a mode that wants to react to a click wants to react however the click was modified.

Only the macOS tap reports these today; the buttons stay in the shared vocabulary the way Insert and F21–F24 do, so one config file still validates on every platform.

Related Issues

Closes #1417

Target Platform

  • Platform-agnostic (shared logic, no OS-specific code)
  • macOS
  • Linux
  • Windows

Type of Change

  • feat — New feature
  • fix — Bug fix
  • refactor — Code restructuring (no behavior change)
  • perf — Performance improvement
  • docs — Documentation only
  • test — Adding or updating tests
  • chore — Build, CI, dependencies, tooling

Cross-Platform Checklist

  • OS-specific files use correct build tags (e.g., //go:build darwin) — the new code sits in existing _darwin.m sources, which carry no Go build tag of their own
  • No darwin imports from untagged (shared) code — The One Rule
  • Stub implementations added for other platforms (returning CodeNotSupported) — N/A, nothing new is added to a port; the Linux and Windows taps simply never emit these names, the same shape as Insert and F21–F24
  • N/A — This PR does not touch platform-specific code

General Checklist

  • Code formatted (just fmt)
  • just ci passes — the exact checks CI runs (format check, lint, vet,
    tests including -race, vulnerability scan, build)
  • Tests added/updated for new or changed functionality
  • Documentation updated (if applicable)
  • Commit messages follow conventional commits

Config surface

Three new named keys, valid in any mode's hotkey table:

Key Fires on
MouseLeft physical left button press
MouseRight physical right button press
MouseMiddle physical middle button press (button 2 only)
[recursive_grid.hotkeys]
"MouseLeft" = "idle"    # clicking anywhere closes the grid

They are reported only while a mode is active, so they belong in a mode table. Writing one in the global [hotkeys] table is refused with an error naming the mode tables instead — a global hotkey resolves to a virtual key code and a mouse button has none, so accepting it would produce a binding that parses and never fires.

No existing option changes, no default changes, and every configuration that loads today still loads: these spellings were already refused as an invalid key format before the vocabulary carried them, so the global-table refusal preserves the behavior they already had.

Additional Context

Why bindable keys rather than a setting. A boolean such as exit_on_external_click would answer only the one question, could not express right- or middle-click, and would add a config option. Making the buttons bindable adds none, composes with macros, and works in every mode.

Telling a physical click from Neru's own. The tap already ignores events Neru posts, by a marker the keyboard feed sets. The mouse path was not setting it; it is now set in the single helper every synthetic mouse event is posted through, which is what keeps action left_click from firing a MouseLeft binding.

Middle button. Only button number 2 is named MouseMiddle; buttons 3 and up stay unbindable rather than being folded onto the middle button.

Why macOS only. Not a platform limitation, just a different amount of work, so this PR stays scoped to the platform it can verify. The macOS tap is session-wide and was already receiving these events and discarding them by mask, so widening the mask was the change. The evdev capture is device-scoped to keyboards — a device qualifies by reporting Q/W/E/R/Space/Enter, which a mouse does not — so buttons would need a new device class to enumerate and hotplug-track, and a non-grabbing read path, since the existing capture is an exclusive EVIOCGRAB and a button must not be consumed. X11 would need raw XInput2 button events on top of that, and Windows a second low-level hook beside the keyboard one. Happy to follow up on any of these if they are wanted.

Testing. just ci passes locally, and so do the containerized cross-platform runs: just test-linux (CGO on), just test-linux-nocgo, just lint-cross and just test-windows-compile.

The Linux run is what caught the one real cross-platform consequence. TestX11KeysymFor_EveryNamedKeyResolves asserts every named key resolves to an X11 keysym, and a mouse button has none — the three new keys failed it. That test is tagged linux && cgo, so a macOS-only just ci does not compile it. Mouse buttons are now skipped there with the reason recorded: the invariant is about a key a grab could reject, and a button never reaches a grab. Verified in both directions — the three fail without the skip and pass with it.

Beyond the suites, the three buttons were exercised by hand on macOS 15.7.4 against recursive grid: each dismissed the overlay while the click still reached the application underneath.

The mode event tap watched keyboard events only, so a click from a real
mouse — or from a keyboard key that sends one — never reached the active
mode: the click landed, the overlay stayed up, and dismissing it took a
second keystroke. MouseLeft, MouseRight and MouseMiddle are now named
keys a mode binds like any other.

A mode observes a button without consuming it, so the binding runs in
addition to the click reaching the application underneath, and clicks
Neru posts itself are marked so a binding cannot fire on its own action.
Modifiers are not encoded into the name: a modified click reports the
button alone.

A mouse button in the global [hotkeys] table is refused rather than
accepted, because a global hotkey resolves to a virtual key code that no
mouse button has and the binding would never fire. Only the macOS tap
reports these today; they stay in the shared vocabulary as Insert and
F21-F24 do, so one config file still validates everywhere.

Closes y3owk1n#1417
@mitchellngoodman
mitchellngoodman force-pushed the feat/mouse-button-bindings branch from 1f7da04 to 09b8d38 Compare August 9, 2026 15:20
@mitchellngoodman
mitchellngoodman marked this pull request as ready for review August 9, 2026 15:34
@y3owk1n

y3owk1n commented Aug 9, 2026

Copy link
Copy Markdown
Owner

i will comment on this later when i am done and happy with my first round of refactoring the codebase, so that you don't have to rebase that often.

//
// Events Neru posted itself already returned above via the 0x1337 marker
// check, so `action left_click` cannot trigger these.
if (type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown || type == kCGEventOtherMouseDown) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have to also consider what happens when held_repeat.enabled is enabled. Looking at it, I think it will repeat the run forever until the mode is idle, probably not something expected. Maybe a guard in key_dispatch.go is needed around the repeat?

// fire — the silent failure a mode binding exists to avoid. Refusing
// keeps the behavior these keys already had before the vocabulary
// carried them, so no configuration that loads today stops loading.
if keyvocab.IsMouseButton(key) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this test the whole key, i think the validation might get skipped if a user binded it as Cmd+MouseLeft or some sort. Worth a double check.

if (type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown || type == kCGEventOtherMouseDown) {
NSString *buttonName = nil;
if (type == kCGEventLeftMouseDown) {
buttonName = @"MouseLeft";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Referring to the literals of mouse left, right and middle, i think we should add them into the keyvocab nativeKeyEventEmitters for consistency, as that's part of the guardrail to prevent weird cases in the future.

/// Marks events Neru posts itself, so the mode event tap can tell its own
/// synthetic clicks apart from ones the user physically produced. Must match
/// the marker in keyfeed_darwin.m and the check in eventTapCallback.
static const int neruSyntheticMouseEventMarker = 0x1337;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think moving this to accessibility_constants.h probably make more sense.

// IsMouseButton reports whether name is one of the mouse buttons,
// case-insensitively.
func IsMouseButton(name string) bool {
lowered := strings.ToLower(name)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have lowercase helper down below, we probably can just use them for consistency.

// listing them in a diagnostic reads the declaration rather than restating it.
func MouseButtons() []string {
buttons := slices.Clone(mouseButtons)
slices.Sort(buttons)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think sorting this will fight against the comment where it should be reads the declaration no? We will need to pick between either keep sort and rewrite the comments, or another way round.

Comment thread docs/CONFIGURATION.md
reaching the application underneath, so the click still does whatever it
normally would.
- **Modifiers are not encoded.** `Cmd`-click and `Shift`-click both report
`MouseLeft`; there is no `Cmd+MouseLeft`.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no Cmd+MouseLeft, this is true, but we got no validation to reject this also, i think a rephrase is needed.

Comment thread docs/CONFIGURATION.md

See [CLI.md](CLI.md#neru-action-feed) for a full key reference with key codes and platform behavior.

**Mouse buttons** are reported only while a mode is active, so they belong in a

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we have some info about the refusal, i think it's worth to tell the user that the eventtap (mouse clicks in this case) will be ignored when entered the search input mode, probably not alot of users using that, but worth a message i think.

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.

Let a mode react to a click from a real mouse

2 participants