diff --git a/src/macos/view.rs b/src/macos/view.rs index 063ba24c..afbcfa09 100644 --- a/src/macos/view.rs +++ b/src/macos/view.rs @@ -8,8 +8,8 @@ use objc::{ class, declare::ClassDecl, msg_send, - runtime::{Class, Object, Sel}, - sel, sel_impl, + runtime::{Class, Object, Protocol, Sel}, + sel, sel_impl, Encode, Encoding, }; use uuid::Uuid; @@ -220,15 +220,280 @@ unsafe fn create_view_class() -> &'static Class { add_simple_mouse_class_method!(class, mouseEntered, MouseEvent::CursorEntered); add_simple_mouse_class_method!(class, mouseExited, MouseEvent::CursorLeft); - add_simple_keyboard_class_method!(class, keyDown); + // keyDown gets a custom impl that may route through NSTextInputContext + // when a text-input view has focus (see `key_down` below). keyUp and + // flagsChanged still go through the simple dispatch macro. + class.add_method(sel!(keyDown:), key_down as extern "C" fn(&Object, Sel, id)); + + // performKeyEquivalent: runs BEFORE keyDown: in AppKit's dispatch order. + // Hosts (REAPER on macOS) use this hook for their own accelerator + // tables — e.g. matching space against the transport shortcut. If none + // of the views in the responder chain claim the event via this path, + // AppKit then delivers it via keyDown:. Without our override, default + // NSView returns NO, the event bubbles up to the host window, REAPER + // fires its binding, and keyDown: is never called for space. + // + // Route through NSTextInputContext the same way we do in keyDown:, + // claiming the event when a text input has focus. + class.add_method( + sel!(performKeyEquivalent:), + perform_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + add_simple_keyboard_class_method!(class, keyUp); add_simple_keyboard_class_method!(class, flagsChanged); + // NSTextInputClient protocol stubs. + // + // Cocoa text hosts (including REAPER on macOS) use protocol conformance + // on the first-responder NSView as a signal that the view is a text + // editor — if present, the host's key-binding pre-check (e.g. REAPER's + // space-bar-is-transport) is bypassed and the key event is dispatched to + // the view's NSTextInputContext instead. The methods below are mostly + // inert sentinels; the real work still happens in the existing + // `keyDown:` handler via `process_native_key_event` + `trigger_event`. + if let Some(proto) = Protocol::get("NSTextInputClient") { + class.add_protocol(proto); + } + + class.add_method( + sel!(hasMarkedText), + has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, + ); + class.add_method( + sel!(markedRange), + marked_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + class.add_method( + sel!(selectedRange), + selected_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + class.add_method( + sel!(setMarkedText:selectedRange:replacementRange:), + set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), + ); + class.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); + class.add_method( + sel!(validAttributesForMarkedText), + valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, + ); + class.add_method( + sel!(attributedSubstringForProposedRange:actualRange:), + attributed_substring_for_proposed_range + as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, + ); + class.add_method( + sel!(insertText:replacementRange:), + insert_text as extern "C" fn(&Object, Sel, id, NSRange), + ); + class.add_method( + sel!(characterIndexForPoint:), + character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> NSUInteger, + ); + class.add_method( + sel!(firstRectForCharacterRange:actualRange:), + first_rect_for_character_range as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> NSRect, + ); + class.add_method( + sel!(doCommandBySelector:), + do_command_by_selector as extern "C" fn(&Object, Sel, Sel), + ); + class.add_ivar::<*mut c_void>(BASEVIEW_STATE_IVAR); class.register() } +const NSNOT_FOUND: NSUInteger = NSUInteger::MAX; + +/// Local NSRange that implements `objc::Encode`. `cocoa::foundation::NSRange` +/// does not implement `Encode`, so we can't use it in `add_method` signatures +/// on the objc 0.2 API. +#[repr(C)] +#[derive(Copy, Clone)] +struct NSRange { + location: NSUInteger, + length: NSUInteger, +} + +unsafe impl Encode for NSRange { + fn encode() -> Encoding { + let encoding = format!( + "{{_NSRange={}{}}}", + NSUInteger::encode().as_str(), + NSUInteger::encode().as_str() + ); + unsafe { Encoding::from_str(&encoding) } + } +} + +extern "C" fn has_marked_text(_this: &Object, _sel: Sel) -> BOOL { + NO +} + +extern "C" fn marked_range(_this: &Object, _sel: Sel) -> NSRange { + NSRange { location: NSNOT_FOUND, length: 0 } +} + +extern "C" fn selected_range(_this: &Object, _sel: Sel) -> NSRange { + NSRange { location: NSNOT_FOUND, length: 0 } +} + +extern "C" fn set_marked_text( + _this: &Object, + _sel: Sel, + _text: id, + _selected: NSRange, + _replacement: NSRange, +) { +} + +extern "C" fn unmark_text(_this: &Object, _sel: Sel) {} + +extern "C" fn valid_attributes_for_marked_text(_this: &Object, _sel: Sel) -> id { + unsafe { NSArray::arrayWithObjects(nil, &[]) } +} + +extern "C" fn attributed_substring_for_proposed_range( + _this: &Object, + _sel: Sel, + _range: NSRange, + _actual_range: *mut c_void, +) -> id { + nil +} + +extern "C" fn insert_text(this: &Object, _sel: Sel, _text: id, _replacement: NSRange) { + // When `keyDown:` routes the event through NSTextInputContext, AppKit + // decodes the NSEvent according to the keyboard layout + IME state and + // calls back here with the resulting text. The original NSEvent is still + // available via `WindowState::current_key_event`, so we can re-use the + // existing `process_native_key_event` path to produce a `KeyboardEvent` + // with both the physical `Code` and the decoded `Key::Character`. + let state = unsafe { WindowState::from_view(this) }; + let event = state.current_key_event(); + if event == nil { + return; + } + if let Some(key_event) = state.process_native_key_event(event) { + state.trigger_event(Event::Keyboard(key_event)); + } +} + +extern "C" fn character_index_for_point(_this: &Object, _sel: Sel, _point: NSPoint) -> NSUInteger { + NSNOT_FOUND +} + +extern "C" fn first_rect_for_character_range( + _this: &Object, + _sel: Sel, + _range: NSRange, + _actual_range: *mut c_void, +) -> NSRect { + NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0)) +} + +extern "C" fn do_command_by_selector(this: &Object, _sel: Sel, _selector: Sel) { + // For non-printable commands (arrow keys, backspace, enter, escape, + // etc.) AppKit calls this instead of `insertText:`. The stored NSEvent + // already carries a mac-native keyCode that `process_native_key_event` + // maps to the right `Code`, so we re-use the same dispatch path. + let state = unsafe { WindowState::from_view(this) }; + let event = state.current_key_event(); + if event == nil { + return; + } + if let Some(key_event) = state.process_native_key_event(event) { + state.trigger_event(Event::Keyboard(key_event)); + } +} + +/// Handler for `keyDown:`. When a text-input view has focus, route the +/// event through AppKit's NSTextInputContext so that: +/// +/// - the host's key-binding pre-check (e.g. REAPER's space-bar transport +/// shortcut) is bypassed for text-input keys, +/// - IME input (Japanese, Chinese, Korean) and the macOS accent menu +/// work, +/// - dead-key composition (option+e then 'e' → 'é') works. +/// +/// Otherwise fall back to the same dispatch as the simple keyboard +/// macro: translate the NSEvent into a `KeyboardEvent`, report it, and +/// forward to the superclass if the app didn't consume it. +extern "C" fn key_down(this: &Object, _sel: Sel, event: id) { + let state = unsafe { WindowState::from_view(this) }; + + // Route through the text-input pipeline only when the app reports a + // text field has focus. Otherwise preserve the old behaviour so host + // shortcuts still work (e.g. space toggles transport in REAPER when + // no text field is focused). + if state.has_text_focus() { + state.set_current_key_event(event); + let handled: BOOL = unsafe { + let input_context: id = msg_send![this, inputContext]; + if input_context != nil { + msg_send![input_context, handleEvent: event] + } else { + NO + } + }; + state.set_current_key_event(nil); + + if handled == YES { + // NSTextInputContext dispatched via insertText: or + // doCommandBySelector:, which already called trigger_event. + // Do not call super — swallow the event so it does not bubble + // up to the host window. + return; + } + // Fall through. inputContext declined the event (e.g. a Cmd-modified + // key), let the usual path handle it. + } + + if let Some(key_event) = state.process_native_key_event(event) { + let status = state.trigger_event(Event::Keyboard(key_event)); + + if let EventStatus::Ignored = status { + unsafe { + let superclass = msg_send![this, superclass]; + let () = msg_send![super(this, superclass), keyDown: event]; + } + } + } +} + +/// Handler for `performKeyEquivalent:`. AppKit calls this before `keyDown:` +/// so that host accelerator tables (REAPER's transport shortcut, Cmd-combos +/// in menu bars, etc.) can claim an event first. If no view in the +/// responder chain returns YES here, the event is then dispatched via +/// `keyDown:`. Without this override, space is claimed by the host's +/// transport binding via the default `NSWindow performKeyEquivalent:` +/// before our view ever sees the event. +/// +/// When a text input currently has focus we route the event through +/// NSTextInputContext — same pipeline as `keyDown:` — and return YES so +/// AppKit stops the dispatch there. +extern "C" fn perform_key_equivalent(this: &Object, _sel: Sel, event: id) -> BOOL { + let state = unsafe { WindowState::from_view(this) }; + + if !state.has_text_focus() { + return NO; + } + + state.set_current_key_event(event); + let handled: BOOL = unsafe { + let input_context: id = msg_send![this, inputContext]; + if input_context != nil { + msg_send![input_context, handleEvent: event] + } else { + NO + } + }; + state.set_current_key_event(nil); + + handled +} + extern "C" fn property_yes(_this: &Object, _sel: Sel) -> BOOL { YES } @@ -255,12 +520,34 @@ extern "C" fn become_first_responder(this: &Object, _sel: Sel) -> BOOL { if is_key_window { state.trigger_deferrable_event(Event::Window(WindowEvent::Focused)); } + + // Mark our NSTextInputContext as the globally-active input context. Hosts + // that inspect `[NSTextInputContext currentInputContext]` to decide + // whether to apply their own keyboard accelerators (REAPER on macOS + // appears to do this: its CGEventTap swallows space-bar keyDown events + // by default but bypasses the check when a text input is active) see a + // non-nil context and skip accelerator matching. + unsafe { + let input_context: id = msg_send![this, inputContext]; + if input_context != nil { + let _: () = msg_send![input_context, activate]; + } + } + YES } extern "C" fn resign_first_responder(this: &Object, _sel: Sel) -> BOOL { let state = unsafe { WindowState::from_view(this) }; state.trigger_deferrable_event(Event::Window(WindowEvent::Unfocused)); + + unsafe { + let input_context: id = msg_send![this, inputContext]; + if input_context != nil { + let _: () = msg_send![input_context, deactivate]; + } + } + YES } diff --git a/src/macos/window.rs b/src/macos/window.rs index 57bca108..ca88e3e0 100644 --- a/src/macos/window.rs +++ b/src/macos/window.rs @@ -268,6 +268,7 @@ impl<'a> Window<'a> { frame_timer: Cell::new(None), window_info: Cell::new(window_info), deferred_events: RefCell::default(), + current_key_event: Cell::new(nil), }); let window_state_ptr = Rc::into_raw(Rc::clone(&window_state)); @@ -364,6 +365,11 @@ pub(super) struct WindowState { /// Events that will be triggered at the end of `window_handler`'s borrow. deferred_events: RefCell>, + + /// The NSEvent currently being routed through NSTextInputContext, so + /// that `insertText:` / `doCommandBySelector:` callbacks from AppKit + /// can reach back to the original event's keyCode and modifiers. + current_key_event: Cell, } impl WindowState { @@ -420,6 +426,29 @@ impl WindowState { self.keyboard_state.process_native_event(event) } + /// Query the `WindowHandler` for whether a text-input view currently + /// has focus. Used to decide whether to route `keyDown:` through + /// NSTextInputContext on macOS. Returns `false` if the handler is + /// already mutably borrowed (re-entrant call) or the handler returns + /// `false`. + pub(super) fn has_text_focus(&self) -> bool { + match self.window_handler.try_borrow_mut() { + Ok(mut handler) => { + let mut window = crate::Window::new(Window { inner: &self.window_inner }); + handler.has_text_focus(&mut window) + } + Err(_) => false, + } + } + + pub(super) fn set_current_key_event(&self, event: id) { + self.current_key_event.set(event); + } + + pub(super) fn current_key_event(&self) -> id { + self.current_key_event.get() + } + unsafe fn setup_timer(window_state_ptr: *const WindowState) { extern "C" fn timer_callback(_: *mut __CFRunLoopTimer, window_state_ptr: *mut c_void) { unsafe { diff --git a/src/window.rs b/src/window.rs index 25b53d1e..17f3e6c3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -47,6 +47,23 @@ unsafe impl HasRawWindowHandle for WindowHandle { pub trait WindowHandler { fn on_frame(&mut self, window: &mut Window); fn on_event(&mut self, window: &mut Window, event: Event) -> EventStatus; + + /// Return `true` when the currently focused UI element expects keyboard + /// input as text — for example when a text field is focused. + /// + /// On macOS, baseview uses this to route `keyDown:` through AppKit's + /// `NSTextInputContext` pipeline when the return value is `true`. That + /// lets the host's key-binding pre-check (e.g. REAPER's space-bar + /// transport shortcut) be bypassed for text-input keys while still + /// forwarding non-text keys to the host normally. It also enables IME + /// input (Japanese, Chinese, Korean) and the macOS accent menu on + /// long-press. + /// + /// Default: `false`, which preserves the previous behaviour of always + /// forwarding unconsumed key events up the responder chain. + fn has_text_focus(&mut self, _window: &mut Window) -> bool { + false + } } pub struct Window<'a> {