Skip to content

fix(macos): route first click in non-key window to mouseDown: via hitTest: override#228

Open
paravozz wants to merge 1 commit intoRustAudio:masterfrom
paravozz:fix/first-click-macos
Open

fix(macos): route first click in non-key window to mouseDown: via hitTest: override#228
paravozz wants to merge 1 commit intoRustAudio:masterfrom
paravozz:fix/first-click-macos

Conversation

@paravozz
Copy link
Copy Markdown

@paravozz paravozz commented Apr 23, 2026

Summary

macOS audio-plugin hosts (Ableton Live, REAPER, Bitwig) show a "first-click dead zone" on every baseview-based plugin: when the plugin window isn't key, the first click inside it is absorbed by AppKit as an activation click and never reaches mouseDown:. Every subsequent click works. This is #129 / #202 / #169 — same root cause, different reported symptoms.

This PR fixes it by overriding hitTest: on the root NSView with a narrowly-scoped collapse to self when the hit lands on the internal NSOpenGLView render subview.

Root cause

src/gl/macos.rs:89–99 attaches an NSOpenGLView as a subview of the root to host the GL context. The GL subview covers the root's frame, so [NSView hitTest:] returns the GL subview for every click. NSOpenGLView inherits the default acceptsFirstMouse: which returns NO — so when the plugin window isn't key, AppKit treats the first click as a pure activation click and never dispatches mouseDown: to any view. The root's acceptsFirstMouse: override (which returns YES) is never consulted because AppKit asks the hit-test target.

I verified this with selector-level logging in a real plugin (vizia-plug-based synth) running in both Ableton Live 12.3.7 and REAPER 7.69 on macOS 15.5:

  • mouseMoved: fires normally (tracking areas hand them to the root regardless of hit depth).
  • hitTest: returns an object other than self for every click.
  • acceptsFirstMouse: is never called on any selector I added logging to.
  • mouseDown: is never called on the first click.
  • On the second click, the window is already key (activated by click Add Windows initial code #1), so acceptsFirstMouse: is irrelevant and mouseDown: fires normally.

Why existing proposals don't close this

The fix

extern "C" fn hit_test(this: &Object, _sel: Sel, point: NSPoint) -> id {
    let super_result: id = unsafe {
        let superclass = msg_send![this, superclass];
        msg_send![super(this, superclass), hitTest: point]
    };
    if super_result == nil {
        return nil;
    }
    unsafe {
        let gl_class = class!(NSOpenGLView);
        let is_gl_subview: BOOL = msg_send![super_result, isKindOfClass: gl_class];
        if is_gl_subview == YES {
            return this as *const _ as id;
        }
    }
    super_result
}

Two methods added, registered via add_method in create_view_class:

  • hitTest: — collapses in-bounds hits on NSOpenGLView to self. AppKit now asks the root view about acceptsFirstMouse:, we return YES, the first click is delivered.
  • viewDidMoveToWindow — calls makeFirstResponder: after the view is in its target window. viewWillMoveToWindow: already calls it, but that runs before attachment and can silently fail.

Scope / safety

  • isKindOfClass: NSOpenGLView gate ensures we don't break future legitimate child views (e.g. an NSTextView overlay for IME, NSScrollView). Those keep receiving hits normally.
  • No-op on the software-renderer path. Without an NSOpenGLView child, [super hitTest:] returns either self (in-bounds, no subviews) or nil (out-of-bounds). Neither triggers the collapse.
  • Rendering is unaffected — the NSOpenGLView still exists as a subview and still draws into its GL context. We only redirect event routing, not rendering.
  • Drag-and-drop unaffected — drag uses draggingEntered: / registeredForDraggedTypes:, not hitTest:.
  • Accessibility unaffected — AX uses accessibilityHitTest:, which is separate.

Testing

Tested end-to-end in Ableton Live 12.3.7 and REAPER 7.69 on macOS 15.5:

  • First click on knobs, buttons, header logo → action fires on the first click with the window becoming key simultaneously, matching the single-click-activates behaviour expected of modern plug-in windows.
  • Knob drag (hold + move) works on first press.
  • Button press (mouseDown + mouseUp → WindowEvent::Press sequence) works on first click.
  • Right-click context menu opens on first right-click.
  • Subsequent clicks in the already-key window behave identically to before this PR.

Related

Comment thread src/macos/view.rs
Comment on lines +390 to +396
unsafe {
let gl_class = class!(NSOpenGLView);
let is_gl_subview: BOOL = msg_send![super_result, isKindOfClass: gl_class];
if is_gl_subview == YES {
return this as *const _ as id;
}
}
Copy link
Copy Markdown
Member

@micahrj micahrj Apr 23, 2026

Choose a reason for hiding this comment

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

This is a really strange way to achieve this. Here is a list of alternative approaches that I think would be better, in order from simple to complex:

  1. We literally store a pointer to the specific NSOpenGLView you are checking for in the view field of GlContext. Just check if super_result is equal to that view pointer. I don't know why you would feel the need to check the class of the NSView.
  2. Subclass NSOpenGLView and override hitTest to always return nil.
  3. Get rid of the NSOpenGLView entirely and just create an NSOpenGLContext instead.

Comment thread src/macos/view.rs Outdated
/// hierarchy. `viewWillMoveToWindow:` above also calls
/// `makeFirstResponder:`, but at that point the view is not yet
/// attached to the target window, so the request can silently fail.
/// The retry here runs after the view has been added — the canonical
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the makeFirstResponder: call in viewWillMoveToWindow: does nothing, why would you just leave it there instead of removing it? Either this is the correct place for it, in which case the other call should be removed, or there is some reason to keep the other call around, in which case this comment is incorrect. Which is it?

Comment thread src/macos/view.rs Outdated
Comment on lines +441 to +451
extern "C" fn view_did_move_to_window(this: &Object, _sel: Sel) {
unsafe {
let window: id = msg_send![this, window];
if window != nil {
let _: BOOL = msg_send![window, makeFirstResponder: this];
}

let superclass = msg_send![this, superclass];
let _: () = msg_send![super(this, superclass), viewDidMoveToWindow];
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm pretty skeptical that this part of the change is necessary for the actual bug fix being targeted by this PR (i.e., the first click on the plugin window not registering as an event). Whether Baseview's NSView successfully becomes the first responder or not when it first gets added to the window doesn't seem to have anything to do with the actual problem, which is that mouse events are being routed to the child NSOpenGLView. Have you tested without this change?

If it is actually the case that the call to makeFirstResponder: in viewWillMoveToWindow: does nothing, it does seem like the correct thing would be to move that call to the viewDidMoveToWindow: method (or to just remove it entirely, which also seems fine to me). However, including that change in this PR specifically is misleading, since it makes it seem like it has something to do with the fix for the first click issue.

@micahrj
Copy link
Copy Markdown
Member

micahrj commented Apr 24, 2026

If you change the hitTest: logic to just check for pointer equality with the NSView pointer in GlContext and remove the makeFirstResponder: change (feel free to move it into a separate PR), I will be happy to merge this.

…s mouseDown:

Rust-subclass NSView composes with an NSOpenGLView child (src/gl/macos.rs)
that hosts the OpenGL render context. The GL subview covers the parent
frame, so `[NSView hitTest:]` returns the child for every click.
NSOpenGLView inherits the default `acceptsFirstMouse:` which returns
`NO`, so AppKit treats the first click in a non-key window as a pure
activation click — it never dispatches `mouseDown:` to our view. That's
the "first click dead zone" tracked in baseview#129, RustAudio#202 (filed by
Justin Frankel of REAPER), and RustAudio#169.

Override `hitTest:` on the root NSView: respect the superclass bounds/
hidden check (return nil out-of-bounds), then collapse the result to
`self` only when the hit lands on our own GL render subview — compared
by pointer equality against the NSOpenGLView stored in `GlContext`. The
root overrides `acceptsFirstMouse:` → `YES`, so AppKit now asks us, we
accept, and `mouseDown:` is dispatched on the first click.

Pointer equality (rather than `isKindOfClass: NSOpenGLView`) keeps the
redirect scoped exactly to our own render child: any other subview —
consumer-added overlays, future IME NSTextViews, NSScrollView — passes
through unchanged.

Fixes baseview#129 / RustAudio#202 / RustAudio#169.
@paravozz paravozz force-pushed the fix/first-click-macos branch from ce9ff7e to 09a95d3 Compare April 24, 2026 01:30
@paravozz
Copy link
Copy Markdown
Author

paravozz commented Apr 24, 2026

@micahrj Thanks! Force-pushed with both addressed:

  1. hitTest: now uses pointer equality against GlContext.view (added a small ns_view() accessor to reach it from view.rs).
  2. Dropped the viewDidMoveToWindow override. Tested in Ableton and REAPER on macOS, first click works without it.

@micahrj
Copy link
Copy Markdown
Member

micahrj commented Apr 24, 2026

It looks like the macOS build is failing due to this issue in the objc crate: SSheldon/rust-objc#125

I can look into putting up a PR to address this.

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.

The initial click is ignored on macOS, requires mouse movement before the second click works correctly

2 participants