Skip to content
Closed
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
48 changes: 40 additions & 8 deletions crates/warpui/src/platform/mac/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ fn native_key_code_to_key_code(native_key_code: u16) -> Option<KeyCode> {
}
}

/// The key a keystroke carries, from the characters AppKit reported for the key without
/// modifiers. `None` when AppKit reported no character at all.
///
/// A keyboard layout that is not ASCII-capable, such as Korean 2-Set, reports its own
/// letters here (ㅑ for the I key, even with Command held), so a Command keystroke would
/// never match a binding written as `cmd-i`. For a Command keystroke on a non-ASCII
/// character this asks `ascii_capable_key` for the key on the ASCII-capable layout
/// instead, which is what the same key reports when that layout is active. Every other
/// keystroke keeps the layout's own character, so typing and Option combinations are
/// unchanged.
fn keystroke_key(
unmodified_chars: &str,
cmd: bool,
ascii_capable_key: impl FnOnce() -> Option<String>,
) -> Option<String> {
let first_char = unmodified_chars.chars().next()?;
if let Some(named_key) = unicode_char_to_key(first_char as u16) {
return Some(named_key.to_owned());
}
if cmd
&& !first_char.is_ascii()
&& let Some(key) = ascii_capable_key()
{
return Some(key);
}
Some(unmodified_chars.to_owned())
}

/// # Safety
/// This code is only unsafe since it requires interfacing with platform code.
/// Creates an event from a native event, taking in the current window_height and whether this is
Expand Down Expand Up @@ -78,19 +106,19 @@ pub unsafe fn from_native(
.to_str()
.ok()?;

let unmodified_chars = if let Some(first_char) = unmodified_chars.chars().next() {
unicode_char_to_key(first_char as u16).unwrap_or(unmodified_chars)
} else {
return None;
};
let cmd = native_modifiers.contains(NSEventModifierFlags::Command);
let shift = native_modifiers.contains(NSEventModifierFlags::Shift);
let key = keystroke_key(unmodified_chars, cmd, || {
Keycode(native_event.keyCode()).try_to_ascii_capable_key_name(shift)
})?;

let keystroke = Keystroke {
ctrl: native_modifiers.contains(NSEventModifierFlags::Control),
alt: native_modifiers.contains(NSEventModifierFlags::Option),
shift: native_modifiers.contains(NSEventModifierFlags::Shift),
cmd: native_modifiers.contains(NSEventModifierFlags::Command),
shift,
cmd,
meta: false, /* handled separately */
key: unmodified_chars.into(),
key,
};

let characters = native_event.characters();
Expand Down Expand Up @@ -253,3 +281,7 @@ pub unsafe fn from_native(
}
}
}

#[cfg(test)]
#[path = "event_tests.rs"]
mod tests;
60 changes: 60 additions & 0 deletions crates/warpui/src/platform/mac/event_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use super::keystroke_key;

fn not_consulted() -> Option<String> {
panic!("the ASCII-capable layout should not be consulted for this keystroke")
}

#[test]
fn a_command_keystroke_on_a_non_latin_letter_takes_the_ascii_capable_key() {
// Korean 2-Set reports ㅑ for the I key; Cmd+I must still be `cmd-i`.
assert_eq!(
keystroke_key("ㅑ", true, || Some("i".to_owned())).as_deref(),
Some("i")
);
// Shift is part of the translation, the way it is on the ASCII-capable layout.
assert_eq!(
keystroke_key("ㅑ", true, || Some("I".to_owned())).as_deref(),
Some("I")
);
}

#[test]
fn a_keystroke_without_command_keeps_the_layouts_character() {
assert_eq!(
keystroke_key("ㅑ", false, not_consulted).as_deref(),
Some("ㅑ")
);
}

#[test]
fn an_ascii_character_is_left_alone() {
assert_eq!(
keystroke_key("i", true, not_consulted).as_deref(),
Some("i")
);
assert_eq!(
keystroke_key("!", true, not_consulted).as_deref(),
Some("!")
);
}

#[test]
fn a_named_key_keeps_its_name() {
// AppKit reports the up arrow as the private-use character U+F700.
assert_eq!(
keystroke_key("\u{F700}", true, not_consulted).as_deref(),
Some("up")
);
}

#[test]
fn a_layout_that_is_already_ascii_capable_keeps_its_own_letters() {
// German reports ö for its own key, and the ASCII-capable layout is German itself,
// so the lookup returns nothing and the keystroke stays `cmd-ö`.
assert_eq!(keystroke_key("ö", true, || None).as_deref(), Some("ö"));
}

#[test]
fn no_characters_means_no_key() {
assert_eq!(keystroke_key("", true, not_consulted), None);
}
38 changes: 29 additions & 9 deletions crates/warpui/src/platform/mac/keycode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub const CONTROL_KEY: u16 = 4096;
unsafe extern "C" {
fn charToKeyCodes(keyChar: id) -> id;
fn keyCodeToChar(keyCode: NSUInteger, shifted: BOOL) -> id;
fn keyCodeToAsciiCapableChar(keyCode: u16, shifted: BOOL) -> id;
}

pub struct Keycode(pub u16);
Expand All @@ -28,16 +29,19 @@ impl Keycode {
// But clippy isn't smart enough to know that so we silence it here for now.
#[allow(clippy::useless_conversion)]
let key = keyCodeToChar(self.0 as u64, shift_key_pressed.into());
nsstring_to_string(key)
}
}

if key.is_null() {
return None;
}

let key = &*key.cast::<NSString>();
let cstr = key.UTF8String() as *const u8;
std::str::from_utf8(slice::from_raw_parts(cstr, key.len()))
.ok()
.map(|s| s.to_string())
/// The key's name on the current ASCII-capable keyboard layout, the Latin layout the
/// user switches to alongside a layout such as Korean 2-Set. `None` when the current
/// layout is already ASCII-capable, or when the key has no printable character there.
pub fn try_to_ascii_capable_key_name(self, shift_key_pressed: bool) -> Option<String> {
unsafe {
// See `try_to_key_name` for why the conversion is needed.
#[allow(clippy::useless_conversion)]
let key = keyCodeToAsciiCapableChar(self.0, shift_key_pressed.into());
nsstring_to_string(key)
}
}

Expand All @@ -62,6 +66,22 @@ impl Keycode {
}
}

/// # Safety
/// `key` must be null or point to a live `NSString`.
unsafe fn nsstring_to_string(key: id) -> Option<String> {
if key.is_null() {
return None;
}

unsafe {
let key = &*key.cast::<NSString>();
let cstr = key.UTF8String() as *const u8;
std::str::from_utf8(slice::from_raw_parts(cstr, key.len()))
.ok()
.map(|s| s.to_string())
}
}

// Convert modifier flags to Carbon style modifier key mask.
pub fn modifier_code(keystroke: &Keystroke) -> u16 {
let mut code = 0;
Expand Down
31 changes: 31 additions & 0 deletions crates/warpui/src/platform/mac/objc/keycode.m
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,37 @@ UniChar TranslatedUnicodeCharFromKeyCode(CFDataRef layout_data, UInt16 key_code,
}
}

// Convert keycode to its character on the ASCII-capable keyboard layout, the Latin layout
// the user switches to alongside the current one. Returns nil when the current keyboard layout
// is already ASCII-capable, so the caller keeps the character AppKit reported. A layout
// such as Korean 2-Set reports its own letters from charactersIgnoringModifiers (ㅑ for
// the I key), which no Command binding is written in.
NSString* keyCodeToAsciiCapableChar(UInt16 keyCode, BOOL shifted) {
TISInputSourceRef current_layout = TISCopyCurrentKeyboardLayoutInputSource();
CFBooleanRef is_ascii_capable = current_layout
? (CFBooleanRef)TISGetInputSourceProperty(current_layout,
kTISPropertyInputSourceIsASCIICapable)
: NULL;
BOOL current_is_ascii_capable = is_ascii_capable && CFBooleanGetValue(is_ascii_capable);
if (current_layout) CFRelease(current_layout);
if (current_is_ascii_capable) return nil;

TISInputSourceRef ascii_layout = TISCopyCurrentASCIICapableKeyboardLayoutInputSource();
if (!ascii_layout) return nil;
CFDataRef layout_data =
(CFDataRef)(TISGetInputSourceProperty(ascii_layout, kTISPropertyUnicodeKeyLayoutData));

// The shift key representation in Carbon is 1 << 9; see keyCodeToChar.
UInt32 modifier_key_state = shifted ? 1 << 1 : 0;
UniChar translated_char =
TranslatedUnicodeCharFromKeyCode(layout_data, keyCode, modifier_key_state, LMGetKbdLast());
// layout_data belongs to ascii_layout, so release only after translating.
CFRelease(ascii_layout);

if (!layout_data || IsUnicodeControl(translated_char)) return nil;
return [NSString stringWithFormat:@"%C", translated_char];
}

NSArray<NSNumber*>* charToKeyCodes(NSString* keyChar) {
if (keycodeDict == nil) {
keycodeDict = [[NSMutableDictionary alloc] init];
Expand Down
Loading