diff --git a/Cargo.toml b/Cargo.toml index 54d5d7a78d..dd59eee96f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -339,6 +339,7 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } clickhouse = "0.11.6" cocoa = "0.26" +cocoa-foundation = "0.2.0" convert_case = "0.6.0" core-foundation = "0.9.3" core-foundation-sys = "0.8.6" diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index e5f5b0df1b..4c099ad107 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1,6 +1,7 @@ [ { "context": "VimControl && !menu", + "use_layout_keys": true, "bindings": { "i": ["vim::PushOperator", { "Object": { "around": false } }], "a": ["vim::PushOperator", { "Object": { "around": true } }], @@ -171,6 +172,7 @@ }, { "context": "vim_mode == normal", + "use_layout_keys": true, "bindings": { "escape": "editor::Cancel", "ctrl-[": "editor::Cancel", @@ -224,6 +226,7 @@ }, { "context": "VimControl && VimCount", + "use_layout_keys": true, "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand" @@ -231,6 +234,7 @@ }, { "context": "vim_mode == visual", + "use_layout_keys": true, "bindings": { ":": "vim::VisualCommand", "u": "vim::ConvertToLowerCase", @@ -279,6 +283,7 @@ }, { "context": "vim_mode == insert", + "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -304,6 +309,7 @@ }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", + "use_layout_keys": true, "bindings": { "ctrl-p": "editor::ShowCompletions", "ctrl-n": "editor::ShowCompletions" @@ -311,6 +317,7 @@ }, { "context": "vim_mode == replace", + "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -328,6 +335,7 @@ }, { "context": "vim_mode == waiting", + "use_layout_keys": true, "bindings": { "tab": "vim::Tab", "enter": "vim::Enter", @@ -341,6 +349,7 @@ }, { "context": "vim_mode == operator", + "use_layout_keys": true, "bindings": { "escape": "vim::ClearOperators", "ctrl-c": "vim::ClearOperators", @@ -349,6 +358,7 @@ }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs", + "use_layout_keys": true, "bindings": { "w": "vim::Word", "shift-w": ["vim::Word", { "ignorePunctuation": true }], @@ -376,6 +386,7 @@ }, { "context": "vim_operator == c", + "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine", "d": "editor::Rename", // zed specific @@ -384,6 +395,7 @@ }, { "context": "vim_operator == d", + "use_layout_keys": true, "bindings": { "d": "vim::CurrentLine", "s": ["vim::PushOperator", "DeleteSurrounds"], @@ -393,6 +405,7 @@ }, { "context": "vim_operator == gu", + "use_layout_keys": true, "bindings": { "g u": "vim::CurrentLine", "u": "vim::CurrentLine" @@ -400,6 +413,7 @@ }, { "context": "vim_operator == gU", + "use_layout_keys": true, "bindings": { "g shift-u": "vim::CurrentLine", "shift-u": "vim::CurrentLine" @@ -407,6 +421,7 @@ }, { "context": "vim_operator == g~", + "use_layout_keys": true, "bindings": { "g ~": "vim::CurrentLine", "~": "vim::CurrentLine" @@ -414,6 +429,7 @@ }, { "context": "vim_operator == gq", + "use_layout_keys": true, "bindings": { "g q": "vim::CurrentLine", "q": "vim::CurrentLine", @@ -423,6 +439,7 @@ }, { "context": "vim_operator == y", + "use_layout_keys": true, "bindings": { "y": "vim::CurrentLine", "s": ["vim::PushOperator", { "AddSurrounds": {} }] @@ -430,30 +447,35 @@ }, { "context": "vim_operator == ys", + "use_layout_keys": true, "bindings": { "s": "vim::CurrentLine" } }, { "context": "vim_operator == >", + "use_layout_keys": true, "bindings": { ">": "vim::CurrentLine" } }, { "context": "vim_operator == <", + "use_layout_keys": true, "bindings": { "<": "vim::CurrentLine" } }, { "context": "vim_operator == gc", + "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine" } }, { "context": "vim_mode == literal", + "use_layout_keys": true, "bindings": { "ctrl-@": ["vim::Literal", ["ctrl-@", "\u0000"]], "ctrl-a": ["vim::Literal", ["ctrl-a", "\u0001"]], @@ -497,6 +519,7 @@ }, { "context": "BufferSearchBar && !in_replace", + "use_layout_keys": true, "bindings": { "enter": "vim::SearchSubmit", "escape": "buffer_search::Dismiss" @@ -504,6 +527,7 @@ }, { "context": "ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView", + "use_layout_keys": true, "bindings": { // window related commands (ctrl-w X) "ctrl-w": null, @@ -554,6 +578,7 @@ }, { "context": "EmptyPane || SharedScreen || MarkdownPreview || KeyContextView", + "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "g /": "pane::DeploySearch" @@ -562,6 +587,7 @@ { // netrw compatibility "context": "ProjectPanel && not_editing", + "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "%": "project_panel::NewFile", @@ -589,6 +615,7 @@ }, { "context": "OutlinePanel && not_editing", + "use_layout_keys": true, "bindings": { "j": "menu::SelectNext", "k": "menu::SelectPrev", diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 97c8b666c7..1c7e285e7f 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -546,7 +546,6 @@ impl InputExample { impl Render for InputExample { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let num_keystrokes = self.recent_keystrokes.len(); div() .bg(rgb(0xaaaaaa)) .track_focus(&self.focus_handle(cx)) @@ -561,7 +560,7 @@ impl Render for InputExample { .flex() .flex_row() .justify_between() - .child(format!("Keystrokes: {}", num_keystrokes)) + .child(format!("Keyboard {}", cx.keyboard_layout())) .child( div() .border_1() @@ -607,6 +606,7 @@ fn main() { KeyBinding::new("end", End, None), KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None), ]); + let window = cx .open_window( WindowOptions { @@ -642,6 +642,13 @@ fn main() { .unwrap(); }) .detach(); + cx.on_keyboard_layout_change({ + move |cx| { + window.update(cx, |_, cx| cx.notify()).ok(); + } + }) + .detach(); + window .update(cx, |view, cx| { cx.focus_view(&view.text_input); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ffbc757369..a3c097455e 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -243,6 +243,7 @@ pub struct AppContext { pub(crate) windows: SlotMap>, pub(crate) window_handles: FxHashMap, pub(crate) keymap: Rc>, + pub(crate) keyboard_layout: SharedString, pub(crate) global_action_listeners: FxHashMap>>, pending_effects: VecDeque, @@ -252,6 +253,7 @@ pub struct AppContext { // TypeId is the type of the event that the listener callback expects pub(crate) event_listeners: SubscriberSet, pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>, + pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, @@ -279,6 +281,7 @@ impl AppContext { let text_system = Arc::new(TextSystem::new(platform.text_system())); let entities = EntityMap::new(); + let keyboard_layout = SharedString::from(platform.keyboard_layout()); let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(AppContext { @@ -302,6 +305,7 @@ impl AppContext { window_handles: FxHashMap::default(), windows: SlotMap::with_key(), keymap: Rc::new(RefCell::new(Keymap::default())), + keyboard_layout, global_action_listeners: FxHashMap::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), @@ -310,6 +314,7 @@ impl AppContext { event_listeners: SubscriberSet::new(), release_listeners: SubscriberSet::new(), keystroke_observers: SubscriberSet::new(), + keyboard_layout_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), layout_id_buffer: Default::default(), @@ -323,6 +328,19 @@ impl AppContext { init_app_menus(platform.as_ref(), &mut app.borrow_mut()); + platform.on_keyboard_layout_change(Box::new({ + let app = Rc::downgrade(&app); + move || { + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.keyboard_layout = SharedString::from(cx.platform.keyboard_layout()); + cx.keyboard_layout_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + platform.on_quit(Box::new({ let cx = app.clone(); move || { @@ -356,6 +374,27 @@ impl AppContext { } } + /// Get the id of the current keyboard layout + pub fn keyboard_layout(&self) -> &SharedString { + &self.keyboard_layout + } + + /// Invokes a handler when the current keyboard layout changes + pub fn on_keyboard_layout_change(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut AppContext), + { + let (subscription, activate) = self.keyboard_layout_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Gracefully quit the application via the platform's standard routine. pub fn quit(&self) { self.platform.quit(); diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index 2fff62c7b6..ecf9765866 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/crates/gpui/src/keymap/binding.rs @@ -1,3 +1,5 @@ +use collections::HashMap; + use crate::{Action, KeyBindingContextPredicate, Keystroke}; use anyhow::Result; use smallvec::SmallVec; @@ -22,22 +24,37 @@ impl Clone for KeyBinding { impl KeyBinding { /// Construct a new keybinding from the given data. pub fn new(keystrokes: &str, action: A, context_predicate: Option<&str>) -> Self { - Self::load(keystrokes, Box::new(action), context_predicate).unwrap() + Self::load(keystrokes, Box::new(action), context_predicate, None).unwrap() } /// Load a keybinding from the given raw data. - pub fn load(keystrokes: &str, action: Box, context: Option<&str>) -> Result { + pub fn load( + keystrokes: &str, + action: Box, + context: Option<&str>, + key_equivalents: Option<&HashMap>, + ) -> Result { let context = if let Some(context) = context { Some(KeyBindingContextPredicate::parse(context)?) } else { None }; - let keystrokes = keystrokes + let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes .split_whitespace() .map(Keystroke::parse) .collect::>()?; + if let Some(equivalents) = key_equivalents { + for keystroke in keystrokes.iter_mut() { + if keystroke.key.chars().count() == 1 { + if let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap()) { + keystroke.key = key.to_string(); + } + } + } + } + Ok(Self { keystrokes, action, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 42441c029e..070f29842e 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -169,6 +169,7 @@ pub(crate) trait Platform: 'static { fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); + fn on_keyboard_layout_change(&self, callback: Box); fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { @@ -180,6 +181,7 @@ pub(crate) trait Platform: 'static { fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); + fn keyboard_layout(&self) -> String; fn compositor_name(&self) -> &'static str { "" diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index c18478aead..a2e9af691b 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -138,6 +138,14 @@ impl Platform for P { self.with_common(|common| common.text_system.clone()) } + fn keyboard_layout(&self) -> String { + "unknown".into() + } + + fn on_keyboard_layout_change(&self, _callback: Box) { + // todo(linux) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 4a06ced19c..332c958410 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -136,6 +136,11 @@ unsafe fn build_classes() { open_urls as extern "C" fn(&mut Object, Sel, id, id), ); + decl.add_method( + sel!(onKeyboardLayoutChange:), + on_keyboard_layout_change as extern "C" fn(&mut Object, Sel, id), + ); + decl.register() } } @@ -152,6 +157,7 @@ pub(crate) struct MacPlatformState { text_hash_pasteboard_type: id, metadata_pasteboard_type: id, reopen: Option>, + on_keyboard_layout_change: Option>, quit: Option>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -196,6 +202,7 @@ impl MacPlatform { open_urls: None, finish_launching: None, dock_menu: None, + on_keyboard_layout_change: None, })) } @@ -785,6 +792,10 @@ impl Platform for MacPlatform { self.0.lock().reopen = Some(callback); } + fn on_keyboard_layout_change(&self, callback: Box) { + self.0.lock().on_keyboard_layout_change = Some(callback); + } + fn on_app_menu_action(&self, callback: Box) { self.0.lock().menu_command = Some(callback); } @@ -797,6 +808,22 @@ impl Platform for MacPlatform { self.0.lock().validate_menu_command = Some(callback); } + fn keyboard_layout(&self) -> String { + unsafe { + let current_keyboard = TISCopyCurrentKeyboardLayoutInputSource(); + + let input_source_id: *mut Object = TISGetInputSourceProperty( + current_keyboard, + kTISPropertyInputSourceID as *const c_void, + ); + let input_source_id: *const std::os::raw::c_char = + msg_send![input_source_id, UTF8String]; + let input_source_id = CStr::from_ptr(input_source_id).to_str().unwrap(); + + input_source_id.to_string() + } + } + fn app_path(&self) -> Result { unsafe { let bundle: id = NSBundle::mainBundle(); @@ -1259,6 +1286,16 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { unsafe { let app: id = msg_send![APP_CLASS, sharedApplication]; app.setActivationPolicy_(NSApplicationActivationPolicyRegular); + + let notification_center: *mut Object = + msg_send![class!(NSNotificationCenter), defaultCenter]; + let name = ns_string("NSTextInputContextKeyboardSelectionDidChangeNotification"); + let _: () = msg_send![notification_center, addObserver: this as id + selector: sel!(onKeyboardLayoutChange:) + name: name + object: nil + ]; + let platform = get_mac_platform(this); let callback = platform.0.lock().finish_launching.take(); if let Some(callback) = callback { @@ -1289,6 +1326,20 @@ extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { } } +extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) { + let platform = unsafe { get_mac_platform(this) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_keyboard_layout_change.take() { + drop(lock); + callback(); + platform + .0 + .lock() + .on_keyboard_layout_change + .get_or_insert(callback); + } +} + extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) { let urls = unsafe { (0..urls.count()) @@ -1395,6 +1446,17 @@ unsafe fn ns_url_to_path(url: id) -> Result { } } +#[link(name = "Carbon", kind = "framework")] +extern "C" { + fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut Object; + fn TISGetInputSourceProperty( + inputSource: *mut Object, + propertyKey: *const c_void, + ) -> *mut Object; + + pub static kTISPropertyInputSourceID: CFStringRef; +} + mod security { #![allow(non_upper_case_globals)] use super::*; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 3258ae9af5..59aef9029e 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -162,6 +162,12 @@ impl Platform for TestPlatform { self.text_system.clone() } + fn keyboard_layout(&self) -> String { + "zed.keyboard.example".to_string() + } + + fn on_keyboard_layout_change(&self, _: Box) {} + fn run(&self, _on_finish_launching: Box) { unimplemented!() } diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index e90b2c6ef1..29443afabb 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -197,6 +197,14 @@ impl Platform for WindowsPlatform { self.text_system.clone() } + fn keyboard_layout(&self) -> String { + "unknown".into() + } + + fn on_keyboard_layout_change(&self, _callback: Box) { + // todo(windows) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); let vsync_event = unsafe { Owned::new(CreateEventW(None, false, false, None).unwrap()) }; diff --git a/crates/settings/src/key_equivalents.rs b/crates/settings/src/key_equivalents.rs new file mode 100644 index 0000000000..1c68f48db4 --- /dev/null +++ b/crates/settings/src/key_equivalents.rs @@ -0,0 +1,185 @@ +use collections::HashMap; + +// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range +// without using option. This means that some of our built in keyboard shortcuts do not work +// for those users. +// +// The way macOS solves this problem is to move shortcuts around so that they are all reachable, +// even if the mnemoic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct +// +// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. +// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves +// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position +// as cmd-> on a QWERTY layout. +// +// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö +// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard +// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the +// specific key moves) +// +// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every +// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... +// +// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the +// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: +// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' +// From there I used multi-cursor to produce this match statement. +#[cfg(target_os = "macos")] +pub fn get_key_equivalents(layout: &str) -> Option> { + let (from, to) = match layout { + "com.apple.keylayout.Welsh" => ("#", "£"), + "com.apple.keylayout.Turkmen" => ("qc]Q`|[XV\\^v~Cx}{", "äçöÄžŞňÜÝş№ýŽÇüÖŇ"), + "com.apple.keylayout.Turkish-QWERTY-PC" => ( + "$\\|`'[}^=.#{*+:/~;)(@<,&]>\"", + "+,;<ığÜ&.ç^Ğ(:Ş*>ş=)'Öö/üÇI", + ), + "com.apple.keylayout.Sami-PC" => ( + "}*x\"w[~^/@`]{|<)>W(\\X=Qq&':;", + "Æ(čŊšøŽ&´\"žæØĐ;=:Š)đČ`Áá/ŋÅå", + ), + "com.apple.keylayout.LatinAmerican" => { + ("[^~>`(<\\@{;*&/):]|='}\"", "{&>:<);¿\"[ñ(/'=Ñ}¡*´]¨") + } + "com.apple.keylayout.IrishExtended" => ("#", "£"), + "com.apple.keylayout.Icelandic" => ("[}=:/'){(*&;^|`\"\\>]<~@", "æ´*Ð'ö=Æ)(/ð&Þ<Öþ:´;>\""), + "com.apple.keylayout.German-DIN-2137" => { + ("}~/<^>{`:\\)&=[]@|;#'\"(*", "Ä>ß;&:Ö<Ü#=/*öä\"'ü§´`)(") + } + "com.apple.keylayout.FinnishSami-PC" => { + (")=*\"\\[@{:>';/<|~(]}^`&", "=`(ˆ@ö\"ÖÅ:¨å´;*>)äÄ& { + ("];{`:'*<~=/}\\|&[\"($^)>@", "äåÖ<Ũ(;>`´Ä'*/öˆ)€&=:\"") + } + "com.apple.keylayout.Faroese" => ("}\";/$>^@~`:&[*){|]=(\\<'", "ÐØæ´€:&\"><Æ/å(=Å*ð`)';ø"), + "com.apple.keylayout.Croatian-PC" => { + ("{@~;<=>(&*['|]\":/}^`)\\", "Š\">č;*:)/(šćŽđĆČ'Đ&<=ž") + } + "com.apple.keylayout.Croatian" => ("{@;<~=>(&*['|]\":}^)\\`", "Š\"č;>*:)'(šćŽđĆČĐ&=ž<"), + "com.apple.keylayout.Azeri" => (":{W?./\"[}<]|,>';w", "IÖÜ,ş.ƏöĞÇğ/çŞəıü"), + "com.apple.keylayout.Albanian" => ("\\'~;:|<>`\"@", "ë@>çÇË;:<'\""), + "com.apple.keylayout.SwissFrench" => ( + ":@&'~^)$;\"][\\/#={!|*+`<(>}", + "ü\"/^>&=çè`àé$'*¨ö+£(!<;):ä", + ), + "com.apple.keylayout.Swedish" => ("(]\\\"~$`^{|/>*:;<)&=[}'@", ")ä'^>€<&Ö*´:(Åå;=/`öĨ\""), + "com.apple.keylayout.Swedish-Pro" => { + ("/^*`'{|)$>&<[\\;(~\"}@]:=", "´&(<¨Ö*=€:/;ö'å)>^Ä\"äÅ`") + } + "com.apple.keylayout.Spanish" => ("|!\\<{[:;@`/~].'>}\"^", "\"¡'¿Ññº´!<.>;ç`Ç:¨/"), + "com.apple.keylayout.Spanish-ISO" => ( + "|~`]/:)(<&^>*;#}\"{.\\['@", + "\"><;.º=)¿/&Ç(´·not found¨Ñç'ñ`\"", + ), + "com.apple.keylayout.Portuguese" => (")`/'^\"<];>[:{@}(&*=~", "=<'´&`;~º:çªÇ\"^)/(*>"), + "com.apple.keylayout.Italian" => ( + "*7};8:!5%(1&4]^\\6)32>.à32", + ), + "com.apple.keylayout.Italian-Pro" => { + ("/:@[]'\\=){;|#<\"(*^&`}>~", "'é\"òàìù*=çè§£;^)(&/<°:>") + } + "com.apple.keylayout.Irish" => ("#", "£"), + "com.apple.keylayout.German" => ("=`#'}:)/\"^&]*{;|[<(>~@\\", "*<§´ÄÜ=ß`&/ä(Öü'ö;):>\"#"), + "com.apple.keylayout.French" => ( + "*}7;8:!5%(1&4]\\^6)32>.ç32", + ), + "com.apple.keylayout.French-numerical" => ( + "|!52;][>&@\"%'{)<~7.1/^(}*8#0$9`6\\3:4", + "£1(é)$^/72%5ù¨0.>è;&:69*8!3à4ç<§`\"°'", + ), + "com.apple.keylayout.French-PC" => ( + "!&\"_$}/72>8]#:31)*<%4;6\\-{['@(0|5.`9~^", + "17%°4£:èé/_$3§\"&08.5'!-*)¨^ù29àμ(;<ç>6", + ), + "com.apple.keylayout.Finnish" => ("/^*`)'{|$>&<[\\~;(\"}@]:=", "´&(<=¨Ö*€:/;ö'>å)^Ä\"äÅ`"), + "com.apple.keylayout.Danish" => ("=[;'`{}|>]*^(&@~)<\\/$\":", "`æå¨<ÆØ*:ø(&)/\">=;'´€^Å"), + "com.apple.keylayout.Canadian-CSA" => ("\\?']/><[{}|~`\"", "àÉèçé\"'^¨ÇÀÙùÈ"), + "com.apple.keylayout.British" => ("#", "£"), + "com.apple.keylayout.Brazilian-ABNT2" => ("\"|~?`'/^\\", "`^\"Ç'´ç¨~"), + "com.apple.keylayout.Belgian" => ( + "`3/*<\\8>7#&96@);024(|'1\":$[~5.%^}]{!", + "<\":8.`!/è37ç§20)àé'9£ù&%°4^>(;56*$¨1", + ), + "com.apple.keylayout.Austrian" => ("/^*`'{|)>&<[\\;(~\"}@]:=#", "ß&(<´Ö'=:/;ö#ü)>`Ä\"äÜ*§"), + "com.apple.keylayout.Slovak-QWERTY" => ( + "):9;63'\"]^/+@~>`? ( + "!$`10&:#4^*~{%5')}6/\"[8]97?;<@23>(+", + "14ň+é7\"3č68ŇÚ5ť§0Äž'!úáäíýˇô?2ľš:9%", + ), + "com.apple.keylayout.Polish" => ( + "&)|?,%:;^}]_{!+#(*`/[~<\"$.>'@=\\", + ":\"$Ż.+Łł=)(ćź§]!/_<żó>śę?,ńą%[;", + ), + "com.apple.keylayout.Lithuanian" => ("+#&=!%1*@73^584$26", "ŽĘŲžĄĮąŪČųęŠįūėĖčš"), + "com.apple.keylayout.Hungarian" => ( + "}(*@\"{=/|;>'[`<~\\!$&0#:]^)+", + "Ú)(\"ÁŐóüŰé:áőíÜÍű'!=ö+Éú/ÖÓ", + ), + "com.apple.keylayout.Hungarian-QWERTY" => ( + "=]#>@/&<`0')~(\\!:*;$\"+^{|}[", + "óú+:\"ü=ÜíöáÖÍ)ű'É(é!ÁÓ/ŐŰÚő", + ), + "com.apple.keylayout.Czech-QWERTY" => ( + "9>0[2()\"}@]46%5;#8{*7^~+!3?&'<$/1`:", + "í:éúě90!(2)čž5řů3áÚ8ý6`%1šˇ7§?4'+¨\"", + ), + "com.apple.keylayout.Maltese" => ("[`}{#]~", "ġżĦĠ£ħŻ"), + "com.apple.keylayout.Turkish" => ( + "|}(#>&^-/`$%@]~*,[\"<_.{:'\\)", + "ÜI%\"Ç)/ş.<'(*ı>_öğ-ÖŞçĞ$,ü:", + ), + "com.apple.keylayout.Turkish-Standard" => { + ("|}(#>=&^`@]~*,;[\"<.{:'\\)", "ÜI)^;*'&ö\"ıÖ(.çğŞ:,ĞÇşü=") + } + "com.apple.keylayout.NorwegianSami-PC" => { + ("\"}~<`&>':{@*^|\\)=([]/;", "ˆÆ>; { + (";\\@>&'<]\"|(=}^)`[~:*{", "čž\":'ć;đĆŽ)*Đ&=<š>Č(Š") + } + "com.apple.keylayout.Slovenian" => ("]`^@)&\":'*=<{;}(~>\\|[", "đ<&\"='ĆČć(*;ŠčĐ)>:žŽš"), + "com.apple.keylayout.SwedishSami-PC" => { + ("@=<^|`>){'&\"}]~[/:*\\(;", "\"`;&*<:=Ö¨/ˆÄä>ö´Å(@)å") + } + "com.apple.keylayout.SwissGerman" => ( + "={#:\\}!(+]/<\";$'`*[>&^~@)|", + "¨é*è$à+)!ä';`üç^<(ö:/&>\"=£", + ), + "com.apple.keylayout.Hawaiian" => ("'", "ʻ"), + "com.apple.keylayout.NorthernSami" => ( + ":/[<{X\"wQx\\(;~>W}`*@])'^|=q&", + "Å´ø;ØČŊšÁčđ)åŽ:ŠÆž(\"æ=ŋ&Đ`á/", + ), + "com.apple.keylayout.USInternational-PC" => ("^~", "ˆ˜"), + "com.apple.keylayout.NorwegianExtended" => ("^~", "ˆ˜"), + "com.apple.keylayout.Norwegian" => ("`'~\"\\*|=/@)[:}&><]{(^;", "<¨>^@(*`´\"=øÅÆ/:;æØ)&å"), + "com.apple.keylayout.ABC-QWERTZ" => { + ("\"}~<`>'&#:{@*^|\\)=(]/;[", "`Ä>;<:´/§ÜÖ\"(&'#=*)äßüö") + } + "com.apple.keylayout.ABC-AZERTY" => ( + ">[$61%@7|)&8\":}593(.4^8:ùà", + ), + "com.apple.keylayout.Czech" => ( + "(7*#193620?/{)@~!$8+;:%4\">`^]&5}[<'", + "9ý83+íšžěéˇ'Ú02`14á%ů\"5č!:¨6)7ř(ú?§", + ), + "com.apple.keylayout.Brazilian-Pro" => ("^~", "ˆ˜"), + _ => { + return None; + } + }; + debug_assert!(from.chars().count() == to.chars().count()); + + Some(HashMap::from_iter(from.chars().zip(to.chars()))) +} + +#[cfg(not(target_os = "macos"))] +pub fn get_key_equivalents(_layout: &str) -> Option> { + None +} diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index ef80b6fbd1..b34806405c 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -19,6 +19,8 @@ pub struct KeymapFile(Vec); pub struct KeymapBlock { #[serde(default)] context: Option, + #[serde(default)] + use_layout_keys: Option, bindings: BTreeMap, } @@ -74,7 +76,14 @@ impl KeymapFile { } pub fn add_to_cx(self, cx: &mut AppContext) -> Result<()> { - for KeymapBlock { context, bindings } in self.0 { + let key_equivalents = crate::key_equivalents::get_key_equivalents(&cx.keyboard_layout()); + + for KeymapBlock { + context, + use_layout_keys, + bindings, + } in self.0 + { let bindings = bindings .into_iter() .filter_map(|(keystroke, action)| { @@ -110,7 +119,18 @@ impl KeymapFile { ) }) .log_err() - .map(|action| KeyBinding::load(&keystroke, action, context.as_deref())) + .map(|action| { + KeyBinding::load( + &keystroke, + action, + context.as_deref(), + if use_layout_keys.unwrap_or_default() { + None + } else { + key_equivalents.as_ref() + }, + ) + }) }) .collect::>>()?; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 40c371d995..a045f793b3 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -1,5 +1,6 @@ mod editable_setting_control; mod json_schema; +mod key_equivalents; mod keymap_file; mod settings_file; mod settings_store; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0afb784cb4..43c8916593 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4730,6 +4730,7 @@ impl Render for Workspace { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let mut context = KeyContext::new_with_defaults(); context.add("Workspace"); + context.set("keyboard_layout", cx.keyboard_layout().clone()); let centered_layout = self.centered_layout && self.center.panes().len() == 1 && self.active_item(cx).is_some(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3855639ffd..3b7c5703bc 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -808,6 +808,7 @@ pub fn handle_keymap_file_changes( VimModeSetting::register(cx); let (base_keymap_tx, mut base_keymap_rx) = mpsc::unbounded(); + let (keyboard_layout_tx, mut keyboard_layout_rx) = mpsc::unbounded(); let mut old_base_keymap = *BaseKeymap::get_global(cx); let mut old_vim_enabled = VimModeSetting::get_global(cx).0; cx.observe_global::(move |cx| { @@ -822,6 +823,11 @@ pub fn handle_keymap_file_changes( }) .detach(); + cx.on_keyboard_layout_change(move |_| { + keyboard_layout_tx.unbounded_send(()).ok(); + }) + .detach(); + load_default_keymap(cx); cx.spawn(move |cx| async move { @@ -829,6 +835,7 @@ pub fn handle_keymap_file_changes( loop { select_biased! { _ = base_keymap_rx.next() => {} + _ = keyboard_layout_rx.next() => {} user_keymap_content = user_keymap_file_rx.next() => { if let Some(user_keymap_content) = user_keymap_content { match KeymapFile::parse(&user_keymap_content) { @@ -854,7 +861,7 @@ fn reload_keymaps(cx: &mut AppContext, keymap_content: &KeymapFile) { load_default_keymap(cx); keymap_content.clone().add_to_cx(cx).log_err(); cx.set_menus(app_menus()); - cx.set_dock_menu(vec![MenuItem::action("New Window", workspace::NewWindow)]) + cx.set_dock_menu(vec![MenuItem::action("New Window", workspace::NewWindow)]); } pub fn load_default_keymap(cx: &mut AppContext) { diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 0ca1110b03..68db517480 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -23,6 +23,8 @@ The file contains a JSON array of objects with `"bindings"`. If no `"context"` i Within each binding section a [key sequence](#keybinding-syntax) is mapped to an [action](#actions). If conflicts are detected they are resolved as [described below](#precedence). +If you are using a non-QWERTY, Latin-character keyboard, you may want to set `use_layout_keys` to `true`. See [Non-QWERTY keyboards](#non-qwerty-keyboards) for more information. + For example: ```json @@ -58,7 +60,7 @@ Each key press is a sequence of modifiers followed by a key. The modifiers are: - `shift-` The shift key - `fn-` The function key -The keys can be any single unicode codepoint that your keyboard generates (for example `a`, `0`, `£` or `ç`), or any named key (`tab`, `f1`, `shift`, or `cmd`). +The keys can be any single unicode codepoint that your keyboard generates (for example `a`, `0`, `£` or `ç`), or any named key (`tab`, `f1`, `shift`, or `cmd`). If you are using a non-Latin layout (e.g. Cyrillic), you can bind either to the cyrillic character, or the latin character that that key generates with `cmd` pressed. A few examples: @@ -89,7 +91,7 @@ For example: ``` # in an editor, it might look like this: -Workspace os=macos +Workspace os=macos keyboard_layout=com.apple.keylayout.QWERTY Pane Editor mode=full extension=md inline_completion vim_mode=insert @@ -130,6 +132,38 @@ The other kind of conflict that arises is when you have two bindings, one of whi When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you tupe `ctrl-w` to se if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +### Non-QWERTY keyboards + +As of Zed 0.162.0, Zed has some support for non-QWERTY keyboards on macOS. Better support for non-QWERTY keyboards on Linux is planned. + +There are roughly three categories of keyboard to consider: + +Keyboards that support full ASCII (QWERTY, DVORAK, COLEMAK, etc.). On these keyboards bindings are resolved based on the character that would be generated by the key. So to type `cmd-[`, find the key labelled `[` and press it with command. + +Keyboards that are mostly non-ASCII, but support full ASCII when the command key is pressed. For example Cyrillic keyboards, Armenian, Hebrew, etc. On these keyboards bindings are resolved based on the character that would be generated by typing the key with command pressed. So to type `ctrl-a`, find the key that generates `cmd-a`. For these keyboards, keyboard shortcuts are displayed in the app using their ASCII equivalents. If the ASCII-equivalents are not printed on your keyboard, you can use the macOS keyboard viewer and holding down the `cmd` key to find things (though often the ASCII equivalents are in a QWERTY layout). + +Finally keyboards that support extended Latin alphabets (usually ISO keyboards) require the most support. For example French AZERTY, German QWERTZ, etc. On these keyboards it is often not possible to type the entire ASCII range without option. To ensure that shortcuts _can_ be typed without option, keyboard shortcuts are mapped to "key equivalents" in the same way as [macOS](). This mapping is defined per layout, and is a compromise between leaving keyboard shortcuts triggered by the same character they are defined with, keeping shortcuts in the same place as a QWERTY layout, and moving shortcuts out of the way of system shortcuts. + +For example on a German QWERTZ keyboard, the `cmd->` shortcut is moved to `cmd-:` because `cmd->` is the system window switcher and this is where that shortcut is typed on a QWERTY keyboard. `cmd-+` stays the same because + is still typable without option, and as a result, `cmd-[` and `cmd-]` become `cmd-ö` and `cmd-ä`, moving out of the way of the `+` key. + +If you are defining shortcuts in your personal keymap, you can opt-out of the key equivalent mapping by setting `use_layout_keys` to `true` in your keymap: + +```json +[ + { + "bindings": { + "ctrl->": "editor::Indent" // parsed as ctrl-: when a German QWERTZ keyboard is active + } + }, + { + "use_layout_keys": true, + "bindings": { + "ctrl->": "editor::Indent" // remains ctrl-> when a German QWERTZ keyboard is active + } + } +] +``` + ## Tips and tricks ### Disabling a binding