Skip to content

Remap wheel tilt and middle click, with dedicated GUI slots - #23

Open
distractedhero wants to merge 4 commits into
DParent10:mainfrom
distractedhero:feat/pointer-remapping
Open

distractedhero wants to merge 4 commits into
DParent10:mainfrom
distractedhero:feat/pointer-remapping

Conversation

@distractedhero

Copy link
Copy Markdown

Problem

Pointer input can't be remapped. The event tap subscribes to mouse and scroll
events, but eventCallback returns early for anything that isn't keyDown,
keyUp or flagsChanged, so a learned wheel binding is recorded and then
never acted on. Binding wheel tilt or middle click has no effect today.

Approach

HID identifies which physical device produced the input; CGEvent is where the
native action can be suppressed. The two are matched by hardware timestamp
within a 50ms tolerance. A matched pointer event is deferred 25ms rather than
blocked, since HID and CGEvent callbacks share the main run loop, and anything
that fails to match is replayed so unrelated input is never swallowed.

Direction comes from the signed HID pan value rather than the CGEvent delta, so
Natural Scrolling doesn't invert it.

A single physical tilt emits a burst of pan reports, so dispatch is debounced
per direction with a 150ms gesture gap.

Surfacing it in the UI

Wheel controls appear in the mapping window as slots 15, 16 and 17, named Wheel
Tilt Left, Wheel Tilt Right and Wheel Click, in their own card row alongside the
existing DPI slots. They ship unbound, since tilt and click report different HID
usages per device, so each user pairs them through the existing Learn Hardware
Trigger flow. Without these cards the capability would only be reachable by
hand-editing profiles.json.

They are independent slots, so binding the wheel no longer consumes a thumb
button mapping.

Verification

Tested on a Naga V2 HS over Bluetooth. Wheel tilt left and right and wheel click
all perform their mapped actions, native behaviour suppressed, no leakage. 136
HID pan reports collapsed to 14 dispatches across 14 physical tilts. Existing
tests pass, plus new coverage for the router, modifier release, and profile
decoding.

Logging

Per-event tracing is gated behind a NAGA_DEBUG environment variable. A normal
launch previously logged every HID report and mapping lookup; that now stays
silent unless the variable is set. Permission state, event tap state, profile
load counts and errors still log unconditionally.

🤖 Generated with Claude Code

https://claude.ai/code/session_017mrRRGrRu2XN8q5ruiZKkT

distractedhero and others added 3 commits September 13, 2026 19:49
The event tap returned early for anything that was not a keyboard event, so
bindings could never replace middle click or horizontal pan. Learned wheel
controls were recorded but never acted on.

Match device-bound HID observations to auxiliary mouse and horizontal scroll
events by hardware timestamp, deferring 25ms so main-loop HID callbacks can
run, and replay anything unmatched. Suppress matched events and dispatch the
configured action instead. Direction comes from the signed HID pan value, so
it is independent of Natural Scrolling.

One physical tilt emits a burst of pan reports, so debounce per direction:
reports arriving within 150ms extend the gesture rather than firing again.
Verified on a Naga V2 HS where 136 HID reports collapsed to 14 actions across
14 physical tilts.

Expose the capability in the mapping window as slots 15, 16 and 17: Wheel Tilt
Left, Wheel Tilt Right and Wheel Click. They ship unbound because tilt and
click report different HID usages per device, so each user pairs them with
Learn Hardware Trigger. Without these cards the feature is reachable only by
hand-editing profiles.json.

Also releases shortcut modifiers explicitly, accepts "opt" as an Option alias,
and ignores continuous trackpad scrolling and primary/secondary buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mrRRGrRu2XN8q5ruiZKkT
A normal launch logged every HID report, every matched device and every mapping
lookup. Seven wheel tilts produced 136 lines. Route that tracing through
Log.debug, which stays silent unless NAGA_DEBUG is set in the environment.
Permission state, event tap state, profile load counts and errors still log
unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mrRRGrRu2XN8q5ruiZKkT
… document NAGA_DEBUG

- Restore AppDelegate's status item title from main: showing the active
  profile there landed in DParent10#12 and this branch reverted it unintentionally.
- deferMappedPointer runs for every tapped event including keystrokes; check
  the event type before building the bindings dictionary.
- README troubleshooting now explains NAGA_DEBUG and lists the log lines the
  app actually prints today, including the pointer router's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DParent10

Copy link
Copy Markdown
Owner

This is excellent work, @distractedhero — thank you. Matching HID to CGEvent by hardware timestamp and deferring instead of blocking is exactly the right shape, taking direction from the signed HID pan so Natural Scrolling can't flip it is a detail I'd have missed, and the router tests are a real gift. I also want to call out a bonus you may not have noticed: because the router works for any slot, a learned mouse-button trigger now fires on buttons 1–12 too, which closes a limitation I'd documented in the user guide last week.

I pushed one small commit on top of yours (maintainer edit) rather than round-tripping:

  • Menu bar title. Your AppDelegate hunk reverted the active-profile name in the status item, which landed in Fix profile switching from mouse buttons #12 a few days before your branch. I've restored it. If the title was too wide on your setup, say so and we can shorten it deliberately.
  • Bindings lookup placement. deferMappedPointer runs for every tapped event including keystrokes, so I moved the hardwareBindingsForCurrentProfile() fetch behind the event-type checks.
  • README troubleshooting now explains NAGA_DEBUG (open --env NAGA_DEBUG=1 …) and lists the lines the app actually prints today, since the old ones no longer exist.

One thing I'd like you to add to the PR description rather than change: the ButtonMapper changes — private event source for synthesized keys, key-up carrying the physical flags, and finishShortcut releasing modifiers that aren't physically held — touch every mapping the app sends, and the description doesn't mention them. I can see the motive (a mapped Cmd+Tab otherwise leaves the switcher open), and the tests cover it, but future readers will want that reasoning in the PR. A paragraph is plenty.

I'll do a hardware pass on my V2 HS today (tilt, click, Cmd+Tab, and a plain Cmd+C hold to check the modifier change) and merge once that's clean. Thanks again — this one's been on the wish list since #4.

With only one tilt direction learned, tilting the other way fell through
to the key-press path, whose single-candidate fallback ignores the pan sign
and fired the learned slot twice per burst while the native horizontal scroll
leaked through (seen on a Naga V2 HS). Pointer inputs — auxiliary mouse
buttons and horizontal pan — now stop at the router whether or not they
matched a binding. Also drop the stale AC Pan → DPI Up/Down fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

2 participants