keymap: Allow modifiers as keys (#12047)

Dov Alperin and Conrad Irwin created

It is sometimes desirable to allow modifers to serve as keys themselves
for the purposes of keybinds. For example, the popular keybind in
jetbrains IDEs `shift shift` which opens the file finder.

This change treats modifers in the keymaps as keys themselves if they
are not accompanied by a key they are modifying.

Further this change wires up they key dispatcher to treat modifer change
events as key presses which are considered for matching against
keybinds.


Release Notes:

- Fixes #6460

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/gpui/src/platform/keystroke.rs  |  34 ++++++
crates/gpui/src/window.rs              | 157 +++++++++++++++++++--------
crates/ui/src/components/keybinding.rs |   9 +
docs/src/key-bindings.md               |  13 +
4 files changed, 159 insertions(+), 54 deletions(-)

Detailed changes

crates/gpui/src/platform/keystroke.rs 🔗

@@ -94,6 +94,27 @@ impl Keystroke {
             }
         }
 
+        //Allow for the user to specify a keystroke modifier as the key itself
+        //This sets the `key` to the modifier, and disables the modifier
+        if key.is_none() {
+            if shift {
+                key = Some("shift".to_string());
+                shift = false;
+            } else if control {
+                key = Some("control".to_string());
+                control = false;
+            } else if alt {
+                key = Some("alt".to_string());
+                alt = false;
+            } else if platform {
+                key = Some("platform".to_string());
+                platform = false;
+            } else if function {
+                key = Some("function".to_string());
+                function = false;
+            }
+        }
+
         let key = key.ok_or_else(|| anyhow!("Invalid keystroke `{}`", source))?;
 
         Ok(Keystroke {
@@ -186,6 +207,10 @@ impl std::fmt::Display for Keystroke {
             "right" => '→',
             "tab" => '⇥',
             "escape" => '⎋',
+            "shift" => '⇧',
+            "control" => '⌃',
+            "alt" => '⌥',
+            "platform" => '⌘',
             key => {
                 if key.len() == 1 {
                     key.chars().next().unwrap().to_ascii_uppercase()
@@ -241,6 +266,15 @@ impl Modifiers {
         }
     }
 
+    /// How many modifier keys are pressed
+    pub fn number_of_modifiers(&self) -> u8 {
+        self.control as u8
+            + self.alt as u8
+            + self.shift as u8
+            + self.platform as u8
+            + self.function as u8
+    }
+
     /// helper method for Modifiers with no modifiers
     pub fn none() -> Modifiers {
         Default::default()

crates/gpui/src/window.rs 🔗

@@ -549,6 +549,7 @@ pub struct Window {
     pub(crate) focus: Option<FocusId>,
     focus_enabled: bool,
     pending_input: Option<PendingInput>,
+    pending_modifiers: Option<Modifiers>,
     pending_input_observers: SubscriberSet<(), AnyObserver>,
     prompt: Option<RenderablePromptHandle>,
 }
@@ -823,6 +824,7 @@ impl Window {
             focus: None,
             focus_enabled: true,
             pending_input: None,
+            pending_modifiers: None,
             pending_input_observers: SubscriberSet::new(),
             prompt: None,
         })
@@ -3161,70 +3163,129 @@ impl<'a> WindowContext<'a> {
             .dispatch_tree
             .dispatch_path(node_id);
 
+        let mut bindings: SmallVec<[KeyBinding; 1]> = SmallVec::new();
+        let mut pending = false;
+        let mut keystroke: Option<Keystroke> = None;
+
+        if let Some(event) = event.downcast_ref::<ModifiersChangedEvent>() {
+            if let Some(previous) = self.window.pending_modifiers.take() {
+                if event.modifiers.number_of_modifiers() == 0 {
+                    let key = match previous {
+                        modifiers if modifiers.shift => Some("shift"),
+                        modifiers if modifiers.control => Some("control"),
+                        modifiers if modifiers.alt => Some("alt"),
+                        modifiers if modifiers.platform => Some("platform"),
+                        modifiers if modifiers.function => Some("function"),
+                        _ => None,
+                    };
+                    if let Some(key) = key {
+                        let key = Keystroke {
+                            key: key.to_string(),
+                            ime_key: None,
+                            modifiers: Modifiers::default(),
+                        };
+                        let KeymatchResult {
+                            bindings: modifier_bindings,
+                            pending: pending_bindings,
+                        } = self
+                            .window
+                            .rendered_frame
+                            .dispatch_tree
+                            .dispatch_key(&key, &dispatch_path);
+
+                        keystroke = Some(key);
+                        bindings = modifier_bindings;
+                        pending = pending_bindings;
+                    }
+                }
+            } else if event.modifiers.number_of_modifiers() == 1 {
+                self.window.pending_modifiers = Some(event.modifiers);
+            }
+            if keystroke.is_none() {
+                self.finish_dispatch_key_event(event, dispatch_path);
+                return;
+            }
+        }
+
         if let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() {
-            let KeymatchResult { bindings, pending } = self
+            self.window.pending_modifiers.take();
+            let KeymatchResult {
+                bindings: key_down_bindings,
+                pending: key_down_pending,
+            } = self
                 .window
                 .rendered_frame
                 .dispatch_tree
                 .dispatch_key(&key_down_event.keystroke, &dispatch_path);
 
-            if pending {
-                let mut currently_pending = self.window.pending_input.take().unwrap_or_default();
-                if currently_pending.focus.is_some() && currently_pending.focus != self.window.focus
-                {
-                    currently_pending = PendingInput::default();
-                }
-                currently_pending.focus = self.window.focus;
-                currently_pending
-                    .keystrokes
-                    .push(key_down_event.keystroke.clone());
-                for binding in bindings {
-                    currently_pending.bindings.push(binding);
-                }
+            keystroke = Some(key_down_event.keystroke.clone());
 
-                currently_pending.timer = Some(self.spawn(|mut cx| async move {
-                    cx.background_executor.timer(Duration::from_secs(1)).await;
-                    cx.update(move |cx| {
-                        cx.clear_pending_keystrokes();
-                        let Some(currently_pending) = cx.window.pending_input.take() else {
-                            return;
-                        };
-                        cx.pending_input_changed();
-                        cx.replay_pending_input(currently_pending);
-                    })
-                    .log_err();
-                }));
+            bindings = key_down_bindings;
+            pending = key_down_pending;
+        }
 
-                self.window.pending_input = Some(currently_pending);
-                self.pending_input_changed();
+        if pending {
+            let mut currently_pending = self.window.pending_input.take().unwrap_or_default();
+            if currently_pending.focus.is_some() && currently_pending.focus != self.window.focus {
+                currently_pending = PendingInput::default();
+            }
+            currently_pending.focus = self.window.focus;
+            if let Some(keystroke) = keystroke {
+                currently_pending.keystrokes.push(keystroke.clone());
+            }
+            for binding in bindings {
+                currently_pending.bindings.push(binding);
+            }
 
-                self.propagate_event = false;
+            currently_pending.timer = Some(self.spawn(|mut cx| async move {
+                cx.background_executor.timer(Duration::from_secs(1)).await;
+                cx.update(move |cx| {
+                    cx.clear_pending_keystrokes();
+                    let Some(currently_pending) = cx.window.pending_input.take() else {
+                        return;
+                    };
+                    cx.replay_pending_input(currently_pending);
+                    cx.pending_input_changed();
+                })
+                .log_err();
+            }));
 
-                return;
-            } else if let Some(currently_pending) = self.window.pending_input.take() {
-                self.pending_input_changed();
-                if bindings
-                    .iter()
-                    .all(|binding| !currently_pending.used_by_binding(binding))
-                {
-                    self.replay_pending_input(currently_pending)
-                }
-            }
+            self.window.pending_input = Some(currently_pending);
+            self.pending_input_changed();
 
-            if !bindings.is_empty() {
-                self.clear_pending_keystrokes();
+            self.propagate_event = false;
+            return;
+        } else if let Some(currently_pending) = self.window.pending_input.take() {
+            self.pending_input_changed();
+            if bindings
+                .iter()
+                .all(|binding| !currently_pending.used_by_binding(binding))
+            {
+                self.replay_pending_input(currently_pending)
             }
+        }
 
-            self.propagate_event = true;
-            for binding in bindings {
-                self.dispatch_action_on_node(node_id, binding.action.as_ref());
-                if !self.propagate_event {
-                    self.dispatch_keystroke_observers(event, Some(binding.action));
-                    return;
-                }
+        if !bindings.is_empty() {
+            self.clear_pending_keystrokes();
+        }
+
+        self.propagate_event = true;
+        for binding in bindings {
+            self.dispatch_action_on_node(node_id, binding.action.as_ref());
+            if !self.propagate_event {
+                self.dispatch_keystroke_observers(event, Some(binding.action));
+                return;
             }
         }
 
+        self.finish_dispatch_key_event(event, dispatch_path)
+    }
+
+    fn finish_dispatch_key_event(
+        &mut self,
+        event: &dyn Any,
+        dispatch_path: SmallVec<[DispatchNodeId; 32]>,
+    ) {
         self.dispatch_key_down_up_event(event, &dispatch_path);
         if !self.propagate_event {
             return;

crates/ui/src/components/keybinding.rs 🔗

@@ -30,7 +30,7 @@ impl KeyBinding {
         Some(Self::new(key_binding))
     }
 
-    fn icon_for_key(keystroke: &Keystroke) -> Option<IconName> {
+    fn icon_for_key(&self, keystroke: &Keystroke) -> Option<IconName> {
         match keystroke.key.as_str() {
             "left" => Some(IconName::ArrowLeft),
             "right" => Some(IconName::ArrowRight),
@@ -45,6 +45,11 @@ impl KeyBinding {
             "escape" => Some(IconName::Escape),
             "pagedown" => Some(IconName::PageDown),
             "pageup" => Some(IconName::PageUp),
+            "shift" if self.platform_style == PlatformStyle::Mac => Some(IconName::Shift),
+            "control" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
+            "platform" if self.platform_style == PlatformStyle::Mac => Some(IconName::Command),
+            "function" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
+            "alt" if self.platform_style == PlatformStyle::Mac => Some(IconName::Option),
             _ => None,
         }
     }
@@ -80,7 +85,7 @@ impl RenderOnce for KeyBinding {
             .gap(Spacing::Small.rems(cx))
             .flex_none()
             .children(self.key_binding.keystrokes().iter().map(|keystroke| {
-                let key_icon = Self::icon_for_key(keystroke);
+                let key_icon = self.icon_for_key(keystroke);
 
                 h_flex()
                     .flex_none()

docs/src/key-bindings.md 🔗

@@ -50,12 +50,12 @@ Zed has the ability to match against not just a single keypress, but a sequence
 Each key press is a sequence of modifiers followed by a key. The modifiers are:
 
 - `ctrl-` The control key
-- `cmd-` On macOS, this is the command key
-- `alt-` On macOS, this is the option key
+* `cmd-`, `win-` or `super-` for the platform modifier (Command on macOS, Windows key on Windows, and the Super key on Linux).
+- `alt-` for alt (option on macOS)
 - `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 `ç`).
+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`).
 
 A few examples:
 
@@ -64,10 +64,15 @@ A few examples:
    "cmd-k cmd-s": "zed::OpenKeymap", // matches ⌘-k then ⌘-s
    "space e": "editor::Complete", // type space then e
    "ç": "editor::Complete", // matches ⌥-c
+   "shift shift": "file_finder::Toggle", // matches pressing and releasing shift twice
  }
 ```
 
-NOTE: Keys on a keyboard are not always the same as the character they generate. For example `shift-e` actually types `E` (or `alt-c` types `ç`). Zed allows you to match against either the key and its modifiers or the character it generates. This means you can specify `alt-c` or `ç`, but not `alt-ç`. It is usually better to specify the key and its modifiers, as this will work better on different keyboard layouts.
+The `shift-` modifier can only be used in combination with a letter to indicate the uppercase version. For example `shift-g` matches typing `G`. Although on many keyboards shift is used to type punctuation characters like `(`, the keypress is not considered to be modified and so `shift-(` does not match.
+
+The `alt-` modifier can be used on many layouts to generate a different key. For example on macOS US keyboard the combination `alt-c` types `ç`. You can match against either in your keymap file, though by convention Zed spells this combination as `alt-c`.
+
+It is possible to match against typing a modifier key on its own. For example `shift shift` can be used to implement JetBrains search everywhere shortcut. In this case the binding happens on key release instead of key press.
 
 ### Remapping keys