Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 290 additions & 3 deletions src/macos/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
29 changes: 29 additions & 0 deletions src/macos/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -364,6 +365,11 @@ pub(super) struct WindowState {

/// Events that will be triggered at the end of `window_handler`'s borrow.
deferred_events: RefCell<VecDeque<Event>>,

/// 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<id>,
}

impl WindowState {
Expand Down Expand Up @@ -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 {
Expand Down
Loading