From 8e5a17274ada3a8c1793043a0f5f7b00b4048d1e Mon Sep 17 00:00:00 2001 From: paravozz Date: Sat, 18 Apr 2026 19:40:12 +0100 Subject: [PATCH] fix(macos): route keyboard through NSTextInputContext when a text input has focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts NSTextInputClient on the custom NSView and routes `keyDown:` / `performKeyEquivalent:` through `[[self inputContext] handleEvent:]` when the application declares a text input has focus (new `WindowHandler::has_text_focus` trait method, default `false`). `insertText:replacementRange:` uses the NSString `text` parameter AppKit hands us — already IME / dead-key / keyboard-layout decoded — as the `Key::Character`; `doCommandBySelector:` re-uses the stored NSEvent for the command's physical Code. Before this, plugins hosting text inputs on macOS produced a system beep per keypress, couldn't use IME or dead-key composition, and lost keys to the host's accelerator table even when the host was configured to forward them to the plugin. Apps that do not override `has_text_focus()` get the old behaviour. The opt-in avoids changing key semantics for non-text-input views (game controls etc.) that rely on the raw-keycode path. Companion to vizia/vizia#630 and #631 on the consumer side. Addresses vizia/vizia-plug#7. --- src/macos/view.rs | 323 +++++++++++++++++++++++++++++++++++++++++++- src/macos/window.rs | 29 ++++ src/window.rs | 17 +++ 3 files changed, 366 insertions(+), 3 deletions(-) diff --git a/src/macos/view.rs b/src/macos/view.rs index 063ba24c..87ae875d 100644 --- a/src/macos/view.rs +++ b/src/macos/view.rs @@ -4,12 +4,13 @@ use cocoa::appkit::{NSEvent, NSFilenamesPboardType, NSView, NSWindow}; use cocoa::base::{id, nil, BOOL, NO, YES}; use cocoa::foundation::{NSArray, NSPoint, NSRect, NSSize, NSUInteger}; +use keyboard_types::Key; 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 +221,307 @@ 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: AppKit offers the event to every view in the responder chain + // (bottom-up), then to the window, then — only if no view claimed it — + // falls through to keyDown:. Some plugin hosts (REAPER on macOS) + // install key bindings at the window-performKeyEquivalent: level + // (space = transport, etc). Without an override, our view returns NO + // by default, the host's window-level handler claims the key, and + // keyDown: on our view never fires — so users cannot type those + // characters into a focused plugin text input. Tested repro in REAPER: + // without this override, space cannot be typed into a focused textbox + // and instead toggles REAPER's transport. + // + // Route through the same NSTextInputContext path as keyDown: and + // claim the event when a text input has focus; return NO otherwise + // so host accelerators work normally. + 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 + // runs the NSEvent through the keyboard layout, dead-key composition, + // and any active IME, then calls back here with the finished string. + // Use that decoded string for `Key::Character` — re-reading + // `[event characters]` would miss IME-composed or dead-key-composed + // characters because those aren't carried on any single NSEvent. + // + // `text` is documented as `id` — NSString in the common case, + // NSAttributedString if the consumer set marked-text attributes. + // Normalize to NSString via `-string` when needed. + let text_str: id = unsafe { + let is_attributed: BOOL = msg_send![text, isKindOfClass: class!(NSAttributedString)]; + if is_attributed == YES { + msg_send![text, string] + } else { + text + } + }; + let decoded = from_nsstring(text_str); + if decoded.is_empty() { + return; + } + + // Use the stored NSEvent for physical `Code` and `modifiers`; override + // `Key::Character` with what AppKit handed us in `text`. + let state = unsafe { WindowState::from_view(this) }; + let event = state.current_key_event(); + if event == nil { + return; + } + if let Some(mut key_event) = state.process_native_key_event(event) { + key_event.key = Key::Character(decoded); + 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:`. See the comment on the +/// `add_method` registration above for why this override exists; in +/// short: hosts install window-level key bindings at this dispatch +/// stage, so without an override `keyDown:` never fires for keys the +/// host claims (e.g. space → REAPER transport), and users cannot type +/// them into a focused plugin text input. +/// +/// When no text input is focused, return NO so AppKit continues to the +/// host's window-performKeyEquivalent: (host accelerators work +/// normally). When a text input is focused, route through +/// NSTextInputContext — same pipeline as `keyDown:` — and return +/// `handleEvent:`'s result so AppKit stops dispatch at our view when +/// the input context claimed the event. +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 +548,36 @@ 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 + // while this view has focus. Required for REAPER's "Send all + // keyboard input to plug-in" path: with that toggle enabled, REAPER + // delivers modifier-combined keys (Cmd shortcuts, Cmd held alone) + // to the active input context rather than routing them directly to + // the NSView. Without `activate` here, those keys get dispatched + // into an inactive context and are either dropped or misinterpreted + // (Cmd held alone was observed firing `deleteBackward:` in practice). + 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> {