macOS: add WindowEvent::PointerButton::is_macos_activation_click

Apps need per-click decisions to follow the macOS convention of accepting
first mouse for low-risk actions (selection, scrolling) but rejecting it
for buttons and destructive actions. Motivated by slint-ui/slint#10451.

Always return `true` from `acceptsFirstMouse:`, and tag the resulting
`PointerButton` events with `is_macos_activation_click: true` (on both
the activating left press and its matching release) so the app can
short-circuit the whole gesture with a single check:

    WindowEvent::PointerButton { is_macos_activation_click: true, .. } => return,

Replaces an earlier callback-based design (`accepts_first_mouse` on
`ApplicationHandlerExtMacOS`) that mapped more closely to AppKit but
didn't fit winit's event-driven model and required re-entrancy handling.

Also removes `WindowAttributesMacOS::with_accepts_first_mouse`, which is
now redundant — apps that want to reject activation clicks check the
flag on the per-event instead.
This commit is contained in:
Till Adam
2026-06-18 22:39:35 +02:00
committed by Simon Hausmann
parent 92b643c3f1
commit 6a8a334426
15 changed files with 95 additions and 21 deletions

View File

@@ -383,6 +383,7 @@ impl EventLoop {
android_activity::input::ToolType::Mouse => continue,
_ => event::ButtonSource::Unknown(0),
},
is_macos_activation_click: false,
};
app.window_event(&self.window_target, GLOBAL_WINDOW, event);
},
@@ -426,6 +427,7 @@ impl EventLoop {
android_activity::input::ToolType::Mouse => continue,
_ => event::ButtonSource::Unknown(0),
},
is_macos_activation_click: false,
};
app.window_event(&self.window_target, GLOBAL_WINDOW, event);
}

View File

@@ -367,7 +367,6 @@ pub struct WindowAttributesMacOS {
pub(crate) fullsize_content_view: bool,
pub(crate) disallow_hidpi: bool,
pub(crate) has_shadow: bool,
pub(crate) accepts_first_mouse: bool,
pub(crate) tabbing_identifier: Option<String>,
pub(crate) option_as_alt: OptionAsAlt,
pub(crate) borderless_game: bool,
@@ -431,13 +430,6 @@ impl WindowAttributesMacOS {
self
}
/// Window accepts click-through mouse events.
#[inline]
pub fn with_accepts_first_mouse(mut self, accepts_first_mouse: bool) -> Self {
self.accepts_first_mouse = accepts_first_mouse;
self
}
/// Defines the window tabbing identifier.
///
/// <https://developer.apple.com/documentation/appkit/nswindow/1644704-tabbingidentifier>
@@ -506,7 +498,6 @@ impl Default for WindowAttributesMacOS {
fullsize_content_view: false,
disallow_hidpi: false,
has_shadow: true,
accepts_first_mouse: true,
tabbing_identifier: None,
option_as_alt: Default::default(),
borderless_game: false,

View File

@@ -138,7 +138,10 @@ pub struct ViewState {
/// to the application, even during IME
forward_key_to_app: Cell<bool>,
marked_text: RefCell<Retained<NSMutableAttributedString>>,
accepts_first_mouse: bool,
/// Set by `acceptsFirstMouse:` so the next `mouseDown:` can tag its
/// [`WindowEvent::PointerButton`] with `is_macos_activation_click: true`.
next_click_is_activation: Cell<bool>,
/// The state of the `Option` as `Alt`.
option_as_alt: Cell<OptionAsAlt>,
@@ -780,9 +783,18 @@ define_class!(
}
#[unsafe(method(acceptsFirstMouse:))]
fn accepts_first_mouse(&self, _event: &NSEvent) -> bool {
fn accepts_first_mouse(&self, event: Option<&NSEvent>) -> bool {
let _entered = debug_span!("acceptsFirstMouse:").entered();
self.ivars().accepts_first_mouse
// Always accept the click. The following `mouseDown:` is tagged with
// `is_macos_activation_click: true` so the application can decide per click
// whether to act on it.
//
// AppKit may pass nil here for synthetic queries that are not tied to a
// specific click; only set the flag when we actually have an event.
if event.is_some() {
self.ivars().next_click_is_activation.set(true);
}
true
}
}
);
@@ -790,7 +802,6 @@ define_class!(
impl WinitView {
pub(super) fn new(
app_state: &Rc<AppState>,
accepts_first_mouse: bool,
option_as_alt: OptionAsAlt,
mtm: MainThreadMarker,
) -> Retained<Self> {
@@ -808,7 +819,7 @@ impl WinitView {
ime_capabilities: Default::default(),
forward_key_to_app: Default::default(),
marked_text: Default::default(),
accepts_first_mouse,
next_click_is_activation: Default::default(),
option_as_alt: Cell::new(option_as_alt),
});
let this: Retained<Self> = unsafe { msg_send![super(this), init] };
@@ -1155,12 +1166,23 @@ impl WinitView {
self.update_modifiers(event, false);
// `acceptsFirstMouse:` fires only for the left button, so the activation flag is
// applied to the left press and held until the matching left release. This lets
// apps short-circuit both events with a single `if is_macos_activation_click` check
// without having to track gesture state themselves.
let is_macos_activation_click = matches!(button, MouseButton::Left)
&& match button_state {
ElementState::Pressed => self.ivars().next_click_is_activation.get(),
ElementState::Released => self.ivars().next_click_is_activation.replace(false),
};
self.queue_event(WindowEvent::PointerButton {
device_id: None,
primary: true,
state: button_state,
position,
button: button.into(),
is_macos_activation_click,
});
}

View File

@@ -842,12 +842,7 @@ fn new_window(
window.center();
}
let view = WinitView::new(
app_state,
macos_attrs.accepts_first_mouse,
macos_attrs.option_as_alt,
mtm,
);
let view = WinitView::new(app_state, macos_attrs.option_as_alt, mtm);
// The default value of `setWantsBestResolutionOpenGLSurface:` was `false` until
// macos 10.14 and `true` after 10.15, we should set it to `YES` or `NO` to avoid

View File

@@ -329,6 +329,25 @@ pub enum WindowEvent {
primary: bool,
button: ButtonSource,
/// Whether this event is part of the click that activated an otherwise inactive window.
///
/// On macOS, AppKit normally consumes the click that brings a window forward without
/// delivering it as a regular mouse event (controlled by [`acceptsFirstMouse:`]). Winit
/// always delivers it, but tags both the activating press *and* its matching release
/// with this flag so applications can short-circuit the whole gesture with a single
/// check — e.g. ignore activation clicks for destructive or button-like targets while
/// accepting them for low-risk actions (selection, scrolling).
///
/// ## Platform-specific
///
/// - **Only available on macOS.** Always `false` on every other platform.
/// - Only ever `true` for the left mouse button. Intervening drag motion (delivered as
/// [`WindowEvent::PointerMoved`]) is not tagged; applications that care about drags
/// during the activation gesture must track that state themselves.
///
/// [`acceptsFirstMouse:`]: https://developer.apple.com/documentation/appkit/nsview/acceptsfirstmouse(_:)
is_macos_activation_click: bool,
},
/// Multi-finger hold gesture on the touchpad or touchscreen without movement.
@@ -1689,6 +1708,7 @@ mod tests {
state: event::ElementState::Pressed,
position: (0, 0).into(),
button: event::ButtonSource::Unknown(0),
is_macos_activation_click: false,
});
with_window_event(PointerButton {
device_id: None,
@@ -1699,6 +1719,7 @@ mod tests {
finger_id: fid,
force: Some(event::Force::Normalized(0.0)),
},
is_macos_activation_click: false,
});
with_window_event(PinchGesture {
device_id: None,

View File

@@ -460,6 +460,7 @@ impl EventLoop {
state,
position: event_state.mouse_pos.into(),
button: button.into(),
is_macos_activation_click: false,
});
}
},

View File

@@ -571,6 +571,7 @@ impl WinitView {
} else {
ButtonSource::Touch { finger_id, force }
},
is_macos_activation_click: false,
},
});
},
@@ -629,6 +630,7 @@ impl WinitView {
} else {
ButtonSource::Touch { finger_id, force }
},
is_macos_activation_click: false,
},
});
}

View File

@@ -187,6 +187,7 @@ impl PointerHandler for WinitState {
state,
position,
button,
is_macos_activation_click: false,
},
window_id,
);

View File

@@ -67,6 +67,7 @@ impl TouchHandler for WinitState {
state: ElementState::Pressed,
position,
button: ButtonSource::Touch { finger_id, force: None },
is_macos_activation_click: false,
},
window_id,
);
@@ -120,6 +121,7 @@ impl TouchHandler for WinitState {
state: ElementState::Released,
position,
button: ButtonSource::Touch { finger_id, force: None },
is_macos_activation_click: false,
},
window_id,
);

View File

@@ -235,6 +235,7 @@ impl Dispatch2<ZwpTabletToolV2, WinitState> for TabletToolData {
button,
data: data.tool_state.clone(),
},
is_macos_activation_click: false,
}
},
TabletEvent::Left => WindowEvent::PointerLeft {

View File

@@ -296,6 +296,7 @@ impl ActiveEventLoop {
state,
position,
button,
is_macos_activation_click: false,
},
}]));
}
@@ -323,6 +324,7 @@ impl ActiveEventLoop {
state: ElementState::Pressed,
position,
button,
is_macos_activation_click: false,
},
})));
}
@@ -351,6 +353,7 @@ impl ActiveEventLoop {
state: ElementState::Released,
position,
button,
is_macos_activation_click: false,
},
})));
}

View File

@@ -1873,6 +1873,7 @@ unsafe fn public_window_callback_inner(
_ => unreachable!(),
}
.into(),
is_macos_activation_click: false,
});
result = ProcResult::Value(0);
},
@@ -1902,6 +1903,7 @@ unsafe fn public_window_callback_inner(
_ => unreachable!(),
}
.into(),
is_macos_activation_click: false,
});
result = ProcResult::Value(0);
},
@@ -1930,6 +1932,7 @@ unsafe fn public_window_callback_inner(
position,
// 1 is defined as back, 2 as forward; other codes are unexpected.
button: MouseButton::try_from_u8(b).unwrap().into(),
is_macos_activation_click: false,
});
result = ProcResult::Value(0);
},
@@ -1959,6 +1962,7 @@ unsafe fn public_window_callback_inner(
position,
// 1 is defined as back, 2 as forward; other codes are unexpected.
button: MouseButton::try_from_u8(b).unwrap().into(),
is_macos_activation_click: false,
});
result = ProcResult::Value(0);
},
@@ -2018,6 +2022,7 @@ unsafe fn public_window_callback_inner(
state: Pressed,
position,
button: Touch { finger_id, force: None },
is_macos_activation_click: false,
});
} else if util::has_flag(input.dwFlags, TOUCHEVENTF_UP) {
userdata.send_window_event(window, WindowEvent::PointerButton {
@@ -2026,6 +2031,7 @@ unsafe fn public_window_callback_inner(
state: Released,
position,
button: Touch { finger_id, force: None },
is_macos_activation_click: false,
});
userdata.send_window_event(window, WindowEvent::PointerLeft {
device_id: None,
@@ -2187,6 +2193,7 @@ unsafe fn public_window_callback_inner(
state: Pressed,
position,
button,
is_macos_activation_click: false,
});
} else {
userdata.send_window_event(window, WindowEvent::PointerButton {
@@ -2195,6 +2202,7 @@ unsafe fn public_window_callback_inner(
state: Released,
position,
button,
is_macos_activation_click: false,
});
userdata.send_window_event(window, WindowEvent::PointerLeft {
device_id: None,

View File

@@ -1108,6 +1108,7 @@ impl EventProcessor {
state,
position,
button: MouseButton::Left.into(),
is_macos_activation_click: false,
},
xlib::Button2 => WindowEvent::PointerButton {
device_id,
@@ -1115,6 +1116,7 @@ impl EventProcessor {
state,
position,
button: MouseButton::Middle.into(),
is_macos_activation_click: false,
},
xlib::Button3 => WindowEvent::PointerButton {
device_id,
@@ -1122,6 +1124,7 @@ impl EventProcessor {
state,
position,
button: MouseButton::Right.into(),
is_macos_activation_click: false,
},
// Suppress emulated scroll wheel clicks, since we handle the real motion events for
@@ -1151,6 +1154,7 @@ impl EventProcessor {
// Button 8 maps to MouseButton::BACK = 3; 36 maps to MouseButton::Button32.
// 255 is the largest code yielded on X11 (tested).
button: MouseButton::try_from_u8((x - 5) as u8).unwrap().into(),
is_macos_activation_click: false,
},
x @ 37..=0xff => WindowEvent::PointerButton {
device_id,
@@ -1159,6 +1163,7 @@ impl EventProcessor {
position,
// 255 is the largest code yielded on X11 (tested).
button: ButtonSource::Unknown(x as u16),
is_macos_activation_click: false,
},
_ => return,
};
@@ -1450,6 +1455,7 @@ impl EventProcessor {
state: ElementState::Pressed,
position,
button: ButtonSource::Touch { finger_id, force: None },
is_macos_activation_click: false,
};
app.window_event(&self.target, window_id, event);
},
@@ -1469,6 +1475,7 @@ impl EventProcessor {
state: ElementState::Released,
position,
button: ButtonSource::Touch { finger_id, force: None },
is_macos_activation_click: false,
};
app.window_event(&self.target, window_id, event);
let event = WindowEvent::PointerLeft {

View File

@@ -474,8 +474,15 @@ impl ApplicationHandler for Application {
}
}
},
WindowEvent::PointerButton { button, state, .. } => {
WindowEvent::PointerButton { button, state, is_macos_activation_click, .. } => {
info!("Pointer button {button:?} {state:?}");
// On macOS, drop both press and release of the click that activated this
// window — real apps would typically skip destructive or button-target
// actions for such clicks; this example just logs them.
if is_macos_activation_click {
info!("(macOS activation click — ignoring)");
return;
}
let mods = window.modifiers;
if let Some(action) = state
.is_pressed()

View File

@@ -57,6 +57,10 @@ changelog entry.
window to be shown on the same Space as a fullscreen window
(`NSWindowCollectionBehaviorFullScreenAuxiliary`) instead of triggering a Space switch or Split
View tiling.
- Add `WindowEvent::PointerButton::is_macos_activation_click`. On macOS, both the press and
matching release of a click that activated a previously inactive window are tagged, so
applications can ignore activation clicks for buttons or destructive actions while accepting
them for low-risk actions like selection or scrolling. Always `false` on other platforms.
### Changed
@@ -64,6 +68,13 @@ changelog entry.
- On older macOS versions (tested up to 12.7.6), applications now receive mouse movement events for unfocused windows, matching the behavior on other platforms.
- On macOS, using the private API `CGSSetWindowBackgroundBlurRadius` for `Window::set_blur` is now disabled by default. It can be re-enabled using the Cargo feature `private-apple-apis`.
### Removed
- On macOS, remove `WindowAttributesMacOS::with_accepts_first_mouse`. Use the new per-event
`WindowEvent::PointerButton::is_macos_activation_click` flag instead. To preserve the old
`with_accepts_first_mouse(false)` behavior, ignore `PointerButton` press events (and their
matching releases / drags) where `is_macos_activation_click` is `true`.
### Fixed
- On Windows, fix a freeze that occurs when the keyboard layout is switched by