macOS: Allow non-cmd keyboard shortcuts to work on non-Latin layouts (#20336)

Conrad Irwin and Will created

Updates #10972

Release Notes:

- Fixed builtin keybindings that don't require cmd on macOS, non-Latin,
ANSI layouts. For example you can now use ctrl-ա (equivalent to ctrl-a)
on an Armenian keyboard to get to the beginning of the line.

---------

Co-authored-by: Will <will@zed.dev>

Change summary

crates/gpui/src/platform/mac/events.rs | 78 +++++++++++++++++++--------
1 file changed, 55 insertions(+), 23 deletions(-)

Detailed changes

crates/gpui/src/platform/mac/events.rs 🔗

@@ -259,10 +259,7 @@ impl PlatformInput {
 unsafe fn parse_keystroke(native_event: id) -> Keystroke {
     use cocoa::appkit::*;
 
-    let mut chars_ignoring_modifiers = native_event
-        .charactersIgnoringModifiers()
-        .to_str()
-        .to_string();
+    let mut chars_ignoring_modifiers = chars_for_modified_key(native_event.keyCode(), false, false);
     let first_char = chars_ignoring_modifiers.chars().next().map(|ch| ch as u16);
     let modifiers = native_event.modifierFlags();
 
@@ -314,28 +311,41 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
         Some(NSF18FunctionKey) => "f18".to_string(),
         Some(NSF19FunctionKey) => "f19".to_string(),
         _ => {
-            let mut chars_ignoring_modifiers_and_shift =
-                chars_for_modified_key(native_event.keyCode(), false, false);
+            // Cases to test when modifying this:
+            //
+            //          qwerty key | none | cmd   | cmd-shift
+            // * Armenian         s | ս    | cmd-s | cmd-shift-s  (layout is non-ASCII, so we use cmd layout)
+            // * Dvorak+QWERTY    s | o    | cmd-s | cmd-shift-s  (layout switches on cmd)
+            // * Ukrainian+QWERTY s | с    | cmd-s | cmd-shift-s  (macOS reports cmd-s instead of cmd-S)
+            // * Czech            7 | ý    | cmd-ý | cmd-7        (layout has shifted numbers)
+            // * Norwegian        7 | 7    | cmd-7 | cmd-/        (macOS reports cmd-shift-7 instead of cmd-/)
+            // * Russian          7 | 7    | cmd-7 | cmd-&        (shift-7 is . but when cmd is down, should use cmd layout)
+            // * German QWERTZ    ; | ö    | cmd-ö | cmd-Ö        (Zed's shift special case only applies to a-z)
+            let mut chars_with_shift = chars_for_modified_key(native_event.keyCode(), false, true);
 
-            // Honor ⌘ when Dvorak-QWERTY is used.
-            let chars_with_cmd = chars_for_modified_key(native_event.keyCode(), true, false);
-            if command && chars_ignoring_modifiers_and_shift != chars_with_cmd {
-                chars_ignoring_modifiers =
-                    chars_for_modified_key(native_event.keyCode(), true, shift);
-                chars_ignoring_modifiers_and_shift = chars_with_cmd;
-            }
+            // Handle Dvorak+QWERTY / Russian / Armeniam
+            if command || always_use_command_layout() {
+                let chars_with_cmd = chars_for_modified_key(native_event.keyCode(), true, false);
+                let chars_with_both = chars_for_modified_key(native_event.keyCode(), true, true);
 
-            if shift {
-                if chars_ignoring_modifiers_and_shift
-                    == chars_ignoring_modifiers.to_ascii_lowercase()
-                {
-                    chars_ignoring_modifiers_and_shift
-                } else if chars_ignoring_modifiers_and_shift != chars_ignoring_modifiers {
-                    shift = false;
-                    chars_ignoring_modifiers
-                } else {
-                    chars_ignoring_modifiers
+                // We don't do this in the case that the shifted command key generates
+                // the same character as the unshifted command key (Norwegian, e.g.)
+                if chars_with_both != chars_with_cmd {
+                    chars_with_shift = chars_with_both;
+
+                // Handle edge-case where cmd-shift-s reports cmd-s instead of
+                // cmd-shift-s (Ukrainian, etc.)
+                } else if chars_with_cmd.to_ascii_uppercase() != chars_with_cmd {
+                    chars_with_shift = chars_with_cmd.to_ascii_uppercase();
                 }
+                chars_ignoring_modifiers = chars_with_cmd;
+            }
+
+            if shift && chars_ignoring_modifiers == chars_with_shift.to_ascii_lowercase() {
+                chars_ignoring_modifiers
+            } else if shift {
+                shift = false;
+                chars_with_shift
             } else {
                 chars_ignoring_modifiers
             }
@@ -355,6 +365,28 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
     }
 }
 
+fn always_use_command_layout() -> bool {
+    // look at the key to the right of "tab" ('a' in QWERTY)
+    // if it produces a non-ASCII character, but with command held produces ASCII,
+    // we default to the command layout for our keyboard system.
+    let event = synthesize_keyboard_event(0);
+    let without_cmd = unsafe {
+        let event: id = msg_send![class!(NSEvent), eventWithCGEvent: &*event];
+        event.characters().to_str().to_string()
+    };
+    if without_cmd.is_ascii() {
+        return false;
+    }
+
+    event.set_flags(CGEventFlags::CGEventFlagCommand);
+    let with_cmd = unsafe {
+        let event: id = msg_send![class!(NSEvent), eventWithCGEvent: &*event];
+        event.characters().to_str().to_string()
+    };
+
+    with_cmd.is_ascii()
+}
+
 fn chars_for_modified_key(code: CGKeyCode, cmd: bool, shift: bool) -> String {
     // Ideally, we would use `[NSEvent charactersByApplyingModifiers]` but that
     // always returns an empty string with certain keyboards, e.g. Japanese. Synthesizing