Skip to content

Fix macOS modifier tracking to use absolute NSEvent state#184

Open
relh wants to merge 1 commit into
treeform:masterfrom
relh:upstream/macos-absolute-modifier-state
Open

Fix macOS modifier tracking to use absolute NSEvent state#184
relh wants to merge 1 commit into
treeform:masterfrom
relh:upstream/macos-absolute-modifier-state

Conversation

@relh

@relh relh commented Jul 22, 2026

Copy link
Copy Markdown

The bug

On macOS, processFlagsChanged tracks Shift/Control/Option/Command by
toggling per event:

let button = keyCodeToButton[event.keyCode]
if button in window.state.buttonDown:
  window.handleButtonRelease(button)
else:
  window.handleButtonPress(button)

Any modifier transition that happens while the window is not key never
reaches this handler, and the next transition that does arrive gets
interpreted backwards. From that point the modifier is inverted in
window.buttonDown for the rest of the session: windy reports Shift/Ctrl
down whenever the key is physically up, and vice versa.

Minimal repro: hold Shift, click the windy window to focus it, release
Shift. windowDidBecomeKey cleared the button state while Shift was
physically down, so the release is delivered to the toggle as a press
buttonDown now contains a phantom Shift indefinitely, and every
subsequent Shift press/release stays inverted. The same thing happens via
Cmd-Tab with a modifier held, or when a window activates itself while the
user is typing elsewhere. Applications that do exact-modifier shortcut
matching on top of buttonDown then silently drop unmodified keys (a
plain key press resolves as SHIFT-/CTRL-qualified), which is very hard to
diagnose from the app side.

The fix

NSEventTypeFlagsChanged events carry the absolute modifier state in
modifierFlags, so the handler no longer needs to infer edges at all:

  • Shift/Control/Option/Command are synchronized from the absolute mask on
    every flagsChanged — the class bit answers "any key of this modifier
    down", and the IOKit device-dependent bits pick the left/right side.
    A missed edge now self-corrects on the next event instead of latching.
    If a device omits the L/R bits (some virtual keyboards), the changed
    keycode is used as a fallback without disturbing its sibling.
  • windowDidBecomeKey seeds the same absolute state (class property
    NSEvent.modifierFlags) right after clearButtonsTemplate, so a
    modifier held across the focus gain is tracked as down and its upcoming
    release cannot register as a phantom press.
  • CapsLock and other lock-style keys keep the existing toggle semantics.

Also: canBecomeKeyWindow selector registration

WindyWindow registers addMethod "canBecomeKeyWindow:", ... — with a
trailing colon — but the NSWindow key-eligibility check is the
zero-argument getter canBecomeKeyWindow. The override therefore never
installs, and since the AppKit default for borderless windows is NO,
undecorated windy windows can never become key and never receive keyboard
events. Registered with the correct colon-less selector (and the handler
signature reduced to (self, cmd) to match).

Validation

nim check src/windy.nim passes on macOS. Manually reasoned through the
state table for held-across-focus, missed-release, both-sides-held, and
virtual-keyboard (no device bits) cases; the absolute mask makes every
path converge to physical truth on the next flagsChanged.

processFlagsChanged toggled each modifier key per event, so any edge
delivered while the window was not key (focus stolen mid-chord, Cmd-Tab
entry, activation settling during launch) latched an inverted modifier
for the rest of the session. Every unmodified key chord then resolved
with a phantom SHIFT/CTRL/ALT attached, silently retargeting exact-match
consumers.

Shift/Control/Option/Command now derive up/down from the absolute
NSEventModifierFlags state (class bit plus IOKit device bits for the
left/right split), so a missed edge self-corrects on the next
flagsChanged. windowDidBecomeKey seeds the same absolute state after
clearing buttons, so a modifier held across focus gain cannot register
its release as a phantom press. CapsLock keeps toggle semantics.

Also register the NSWindow key-eligibility override with its real
zero-argument selector: 'canBecomeKeyWindow:' (with colon) never
overrode the getter, leaving borderless windows unable to become key.
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