diff --git a/winit-appkit/src/event.rs b/winit-appkit/src/event.rs index 434a1de761..3441b57e5e 100644 --- a/winit-appkit/src/event.rs +++ b/winit-appkit/src/event.rs @@ -307,8 +307,7 @@ pub(super) fn ralt_pressed(event: &NSEvent) -> bool { event.modifierFlags().contains(NX_DEVICERALTKEYMASK) } -pub(super) fn event_mods(event: &NSEvent) -> Modifiers { - let flags = event.modifierFlags(); +pub(super) fn mods_from_flags(flags: NSEventModifierFlags) -> Modifiers { let mut state = ModifiersState::empty(); let mut pressed_mods = ModifiersKeys::empty(); @@ -331,6 +330,37 @@ pub(super) fn event_mods(event: &NSEvent) -> Modifiers { Modifiers::new(state, pressed_mods) } +pub(super) fn event_mods(event: &NSEvent) -> Modifiers { + mods_from_flags(event.modifierFlags()) +} + +/// For each modifier key, returns `(logical_key, left_held, right_held)` +/// based on the device-specific bits in `flags`. +pub(super) fn per_modifier_held(flags: NSEventModifierFlags) -> [(Key, bool, bool); 4] { + [ + ( + Key::Named(NamedKey::Shift), + flags.contains(NX_DEVICELSHIFTKEYMASK), + flags.contains(NX_DEVICERSHIFTKEYMASK), + ), + ( + Key::Named(NamedKey::Control), + flags.contains(NX_DEVICELCTLKEYMASK), + flags.contains(NX_DEVICERCTLKEYMASK), + ), + ( + Key::Named(NamedKey::Alt), + flags.contains(NX_DEVICELALTKEYMASK), + flags.contains(NX_DEVICERALTKEYMASK), + ), + ( + Key::Named(NamedKey::Meta), + flags.contains(NX_DEVICELCMDKEYMASK), + flags.contains(NX_DEVICERCMDKEYMASK), + ), + ] +} + pub(super) fn dummy_event() -> Option> { NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( NSEventType::ApplicationDefined, diff --git a/winit-appkit/src/ffi.rs b/winit-appkit/src/ffi.rs index 7f7cec0bfb..fd986522e0 100644 --- a/winit-appkit/src/ffi.rs +++ b/winit-appkit/src/ffi.rs @@ -28,8 +28,17 @@ unsafe extern "C" { pub fn CGDisplayGetDisplayIDFromUUID(uuid: &CFUUID) -> CGDirectDisplayID; } +pub type CGEventSourceStateID = u32; +pub type CGEventFlags = u64; + +/// Combined session state: union of all event sources in the user session. +pub const kCGEventSourceStateCombinedSessionState: CGEventSourceStateID = 0; + #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { + /// Returns the current modifier flags for the given event source. + pub fn CGEventSourceFlagsState(stateID: CGEventSourceStateID) -> CGEventFlags; + // Wildly used private APIs; Apple uses them for their Terminal.app. pub fn CGSMainConnectionID() -> *mut AnyObject; pub fn CGSSetWindowBackgroundBlurRadius( diff --git a/winit-appkit/src/view.rs b/winit-appkit/src/view.rs index 818e460277..f1574849ec 100644 --- a/winit-appkit/src/view.rs +++ b/winit-appkit/src/view.rs @@ -27,8 +27,8 @@ use winit_core::window::ImeCapabilities; use super::app_state::AppState; use super::cursor::{default_cursor, invisible_cursor}; use super::event::{ - code_to_key, code_to_location, create_key_event, event_mods, lalt_pressed, ralt_pressed, - scancode_to_physicalkey, + code_to_key, code_to_location, create_key_event, event_mods, lalt_pressed, mods_from_flags, + per_modifier_held, ralt_pressed, scancode_to_physicalkey, }; use super::window::window_id; use crate::OptionAsAlt; @@ -109,6 +109,32 @@ fn get_left_modifier_code(key: &Key) -> KeyCode { } } +fn synthetic_modifier_key_event( + logical_key: &Key, + location: KeyLocation, + state: ElementState, +) -> WindowEvent { + let physical_key = match location { + KeyLocation::Left => get_left_modifier_code(logical_key), + KeyLocation::Right => get_right_modifier_code(logical_key), + _ => unreachable!(), + }; + WindowEvent::KeyboardInput { + device_id: None, + event: KeyEvent { + physical_key: physical_key.into(), + logical_key: logical_key.clone(), + text: None, + location, + state, + repeat: false, + text_with_all_modifiers: None, + key_without_modifiers: logical_key.clone(), + }, + is_synthetic: true, + } +} + #[derive(Debug)] pub struct ViewState { /// Strong reference to the global application state. @@ -931,14 +957,101 @@ impl WinitView { input_context.invalidateCharacterCoordinates(); } - /// Reset modifiers and emit a synthetic ModifiersChanged event if deemed necessary. - pub(super) fn reset_modifiers(&self) { + /// Emit synthetic key-release events for all tracked modifier keys, + /// then clear tracking state and modifiers. Called on focus loss. + pub(super) fn synthesize_modifier_key_releases(&self) { + let mut phys_mod_state = self.ivars().phys_modifiers.borrow_mut(); + + for (logical_key, location_mask) in phys_mod_state.drain() { + if location_mask.contains(ModLocationMask::LEFT) { + self.queue_event(synthetic_modifier_key_event( + &logical_key, + KeyLocation::Left, + ElementState::Released, + )); + } + if location_mask.contains(ModLocationMask::RIGHT) { + self.queue_event(synthetic_modifier_key_event( + &logical_key, + KeyLocation::Right, + ElementState::Released, + )); + } + } + if !self.ivars().modifiers.get().state().is_empty() { self.ivars().modifiers.set(Modifiers::default()); self.queue_event(WindowEvent::ModifiersChanged(self.ivars().modifiers.get())); } } + /// Query hardware modifier state via `CGEventSourceFlagsState` and + /// emit synthetic key events + `ModifiersChanged` for any differences + /// against `phys_modifiers`. Called on focus gain. + pub(super) fn synchronize_modifiers(&self) { + use objc2_app_kit::NSEventModifierFlags; + + use super::ffi::{ + CGEventFlags, CGEventSourceFlagsState, kCGEventSourceStateCombinedSessionState, + }; + + // CGEventFlags and NSEventModifierFlags share the IOHIDFamily + // NX_DEVICE* bit layout. See IOLLEvent.h. + const _: () = assert!( + size_of::() <= size_of::(), + "CGEventFlags must fit in NSEventModifierFlags (NSUInteger)", + ); + + let cg_flags = unsafe { CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState) }; + let flags = NSEventModifierFlags(cg_flags as usize); + + let mut phys_mod_state = self.ivars().phys_modifiers.borrow_mut(); + + for (logical_key, left_held, right_held) in per_modifier_held(flags) { + let old = phys_mod_state.get(&logical_key).copied().unwrap_or(ModLocationMask::empty()); + + let mut new_mask = ModLocationMask::empty(); + + if left_held != old.contains(ModLocationMask::LEFT) { + let state = if left_held { ElementState::Pressed } else { ElementState::Released }; + self.queue_event(synthetic_modifier_key_event( + &logical_key, + KeyLocation::Left, + state, + )); + } + if left_held { + new_mask |= ModLocationMask::LEFT; + } + + if right_held != old.contains(ModLocationMask::RIGHT) { + let state = if right_held { ElementState::Pressed } else { ElementState::Released }; + self.queue_event(synthetic_modifier_key_event( + &logical_key, + KeyLocation::Right, + state, + )); + } + if right_held { + new_mask |= ModLocationMask::RIGHT; + } + + if new_mask.is_empty() { + phys_mod_state.remove(&logical_key); + } else { + phys_mod_state.insert(logical_key, new_mask); + } + } + + drop(phys_mod_state); + + let modifiers = mods_from_flags(flags); + if modifiers != self.ivars().modifiers.get() { + self.ivars().modifiers.set(modifiers); + self.queue_event(WindowEvent::ModifiersChanged(modifiers)); + } + } + pub(super) fn set_option_as_alt(&self, value: OptionAsAlt) { self.ivars().option_as_alt.set(value) } diff --git a/winit-appkit/src/window_delegate.rs b/winit-appkit/src/window_delegate.rs index e316973e7c..d441b2c110 100644 --- a/winit-appkit/src/window_delegate.rs +++ b/winit-appkit/src/window_delegate.rs @@ -187,20 +187,16 @@ define_class!( let _entered = debug_span!("windowDidBecomeKey:").entered(); // TODO: center the cursor if the window had mouse grab when it // lost focus + + self.view().synchronize_modifiers(); + self.queue_event(WindowEvent::Focused(true)); } #[unsafe(method(windowDidResignKey:))] fn window_did_resign_key(&self, _: Option<&AnyObject>) { let _entered = debug_span!("windowDidResignKey:").entered(); - // It happens rather often, e.g. when the user is Cmd+Tabbing, that the - // NSWindowDelegate will receive a didResignKey event despite no event - // being received when the modifiers are released. This is because - // flagsChanged events are received by the NSView instead of the - // NSWindowDelegate, and as a result a tracked modifiers state can quite - // easily fall out of synchrony with reality. This requires us to emit - // a synthetic ModifiersChanged event when we lose focus. - self.view().reset_modifiers(); + self.view().synthesize_modifier_key_releases(); self.queue_event(WindowEvent::Focused(false)); } diff --git a/winit-core/src/event.rs b/winit-core/src/event.rs index e6500c2133..9a488c43a8 100644 --- a/winit-core/src/event.rs +++ b/winit-core/src/event.rs @@ -136,7 +136,7 @@ pub enum WindowEvent { /// /// * Synthetic key press events are generated for all keys pressed when a window gains /// focus. Likewise, synthetic key release events are generated for all keys pressed when - /// a window goes out of focus. ***Currently, this is only functional on X11 and + /// a window goes out of focus. ***Currently, this is only functional on macOS, X11, and /// Windows*** /// /// Otherwise, this value is always `false`. diff --git a/winit/examples/modifier_keys.rs b/winit/examples/modifier_keys.rs new file mode 100644 index 0000000000..ad03736284 --- /dev/null +++ b/winit/examples/modifier_keys.rs @@ -0,0 +1,161 @@ +//! Per-key modifier tracking across focus changes. +//! +//! Tracks modifier key state from `KeyboardInput` events (not `ModifiersChanged`). +//! +//! Green = no modifier keys tracked as pressed. +//! Red = at least one modifier key tracked as pressed. +//! +//! Press C to open a secondary window, then Cmd+W (macOS) or Ctrl+W to close it. +//! Without synthetic key events on focus gain, the primary stays red because the +//! modifier release is lost with the destroyed window. + +use std::collections::HashSet; +use std::error::Error; + +use tracing::info; +use winit::application::ApplicationHandler; +use winit::event::{ElementState, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{Window, WindowAttributes, WindowId}; + +#[path = "util/fill.rs"] +mod fill; +#[path = "util/tracing.rs"] +mod tracing; + +const GREEN: u32 = 0x00208020; +const RED: u32 = 0x00c02020; +const GREY: u32 = 0x00404040; + +#[derive(Default)] +struct App { + primary: Option>, + secondary: Option>, + pressed: HashSet, +} + +impl App { + fn any_mod_held(&self) -> bool { + self.pressed.iter().any(|k| { + matches!( + k, + KeyCode::MetaLeft + | KeyCode::MetaRight + | KeyCode::ControlLeft + | KeyCode::ControlRight + | KeyCode::AltLeft + | KeyCode::AltRight + | KeyCode::ShiftLeft + | KeyCode::ShiftRight + ) + }) + } + + fn redraw_primary(&self) { + if let Some(win) = &self.primary { + let color = if self.any_mod_held() { RED } else { GREEN }; + fill::fill_window_with_color(win.as_ref(), color); + } + } + + fn close_secondary(&mut self) { + if let Some(win) = self.secondary.take() { + fill::cleanup_window(win.as_ref()); + } + } + + fn command_held(&self) -> bool { + if cfg!(target_os = "macos") { + self.pressed.contains(&KeyCode::MetaLeft) || self.pressed.contains(&KeyCode::MetaRight) + } else { + self.pressed.contains(&KeyCode::ControlLeft) + || self.pressed.contains(&KeyCode::ControlRight) + } + } +} + +impl ApplicationHandler for App { + fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { + let attrs = WindowAttributes::default() + .with_title("Per-key tracker: green=clear, red=stuck modifier"); + self.primary = Some(event_loop.create_window(attrs).expect("create primary window")); + } + + fn window_event( + &mut self, + event_loop: &dyn ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let is_primary = self.primary.as_ref().is_some_and(|w| w.id() == window_id); + let is_secondary = self.secondary.as_ref().is_some_and(|w| w.id() == window_id); + + match event { + WindowEvent::CloseRequested if is_secondary => self.close_secondary(), + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::KeyboardInput { event, is_synthetic, .. } => { + if let PhysicalKey::Code(code) = event.physical_key { + let syn = if is_synthetic { " [synthetic]" } else { "" }; + info!("{code:?} {:?}{syn}", event.state); + + match event.state { + ElementState::Pressed => { + self.pressed.insert(code); + }, + ElementState::Released => { + self.pressed.remove(&code); + }, + } + + if code == KeyCode::KeyC + && event.state == ElementState::Pressed + && self.secondary.is_none() + { + let attrs = WindowAttributes::default() + .with_title("Secondary — close with modifier+W") + .with_surface_size(winit::dpi::LogicalSize::new(400, 300)); + self.secondary = + Some(event_loop.create_window(attrs).expect("create secondary window")); + } + + if code == KeyCode::KeyW + && event.state == ElementState::Pressed + && self.command_held() + && is_secondary + { + self.close_secondary(); + } + } + self.redraw_primary(); + }, + WindowEvent::Focused(focused) => { + info!("focused={focused} pressed={:?} (window {window_id:?})", self.pressed); + if is_primary { + self.redraw_primary(); + } + }, + WindowEvent::RedrawRequested if is_primary => self.redraw_primary(), + WindowEvent::RedrawRequested if is_secondary => { + if let Some(win) = &self.secondary { + fill::fill_window_with_color(win.as_ref(), GREY); + } + }, + WindowEvent::SurfaceResized(_) if is_primary => { + self.primary.as_ref().unwrap().request_redraw() + }, + WindowEvent::SurfaceResized(_) if is_secondary => { + self.secondary.as_ref().unwrap().request_redraw() + }, + _ => {}, + } + } +} + +fn main() -> Result<(), Box> { + tracing::init(); + + let event_loop = EventLoop::new()?; + event_loop.run_app(App::default())?; + Ok(()) +} diff --git a/winit/src/changelog/unreleased.md b/winit/src/changelog/unreleased.md index 32910b707c..0b63a7ed7b 100644 --- a/winit/src/changelog/unreleased.md +++ b/winit/src/changelog/unreleased.md @@ -56,6 +56,7 @@ changelog entry. ### Fixed +- On macOS, synthesize per-key `KeyboardInput` press/release events on focus changes, matching X11 and Windows. Previously only `ModifiersChanged` was emitted on focus loss, and nothing on focus gain, leaving per-key modifier tracking stale. - On Redox, handle `EINTR` when reading from `event_socket` instead of panicking. - On Wayland, switch from using the `ahash` hashing algorithm to `foldhash`. - On macOS, fix borderless game presentation options not sticking after switching spaces.