keymap: Allow modifiers as keys (#12047)

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>
This commit is contained in:
Dov Alperin 2024-06-25 12:17:23 -04:00 committed by GitHub
parent 7e694d1bcf
commit 0b1a589183
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 161 additions and 56 deletions

View File

@ -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))?; let key = key.ok_or_else(|| anyhow!("Invalid keystroke `{}`", source))?;
Ok(Keystroke { Ok(Keystroke {
@ -186,6 +207,10 @@ impl std::fmt::Display for Keystroke {
"right" => '→', "right" => '→',
"tab" => '⇥', "tab" => '⇥',
"escape" => '⎋', "escape" => '⎋',
"shift" => '⇧',
"control" => '⌃',
"alt" => '⌥',
"platform" => '⌘',
key => { key => {
if key.len() == 1 { if key.len() == 1 {
key.chars().next().unwrap().to_ascii_uppercase() 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 /// helper method for Modifiers with no modifiers
pub fn none() -> Modifiers { pub fn none() -> Modifiers {
Default::default() Default::default()

View File

@ -549,6 +549,7 @@ pub struct Window {
pub(crate) focus: Option<FocusId>, pub(crate) focus: Option<FocusId>,
focus_enabled: bool, focus_enabled: bool,
pending_input: Option<PendingInput>, pending_input: Option<PendingInput>,
pending_modifiers: Option<Modifiers>,
pending_input_observers: SubscriberSet<(), AnyObserver>, pending_input_observers: SubscriberSet<(), AnyObserver>,
prompt: Option<RenderablePromptHandle>, prompt: Option<RenderablePromptHandle>,
} }
@ -823,6 +824,7 @@ impl Window {
focus: None, focus: None,
focus_enabled: true, focus_enabled: true,
pending_input: None, pending_input: None,
pending_modifiers: None,
pending_input_observers: SubscriberSet::new(), pending_input_observers: SubscriberSet::new(),
prompt: None, prompt: None,
}) })
@ -3161,70 +3163,129 @@ impl<'a> WindowContext<'a> {
.dispatch_tree .dispatch_tree
.dispatch_path(node_id); .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>() { 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 .window
.rendered_frame .rendered_frame
.dispatch_tree .dispatch_tree
.dispatch_key(&key_down_event.keystroke, &dispatch_path); .dispatch_key(&key_down_event.keystroke, &dispatch_path);
if pending { keystroke = Some(key_down_event.keystroke.clone());
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);
}
currently_pending.timer = Some(self.spawn(|mut cx| async move { bindings = key_down_bindings;
cx.background_executor.timer(Duration::from_secs(1)).await; pending = key_down_pending;
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();
}));
self.window.pending_input = Some(currently_pending); if pending {
self.pending_input_changed(); let mut currently_pending = self.window.pending_input.take().unwrap_or_default();
if currently_pending.focus.is_some() && currently_pending.focus != self.window.focus {
self.propagate_event = false; currently_pending = PendingInput::default();
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)
}
} }
currently_pending.focus = self.window.focus;
if !bindings.is_empty() { if let Some(keystroke) = keystroke {
self.clear_pending_keystrokes(); currently_pending.keystrokes.push(keystroke.clone());
} }
self.propagate_event = true;
for binding in bindings { for binding in bindings {
self.dispatch_action_on_node(node_id, binding.action.as_ref()); currently_pending.bindings.push(binding);
if !self.propagate_event { }
self.dispatch_keystroke_observers(event, Some(binding.action));
return; 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();
}));
self.window.pending_input = Some(currently_pending);
self.pending_input_changed();
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)
} }
} }
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); self.dispatch_key_down_up_event(event, &dispatch_path);
if !self.propagate_event { if !self.propagate_event {
return; return;

View File

@ -30,7 +30,7 @@ impl KeyBinding {
Some(Self::new(key_binding)) 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() { match keystroke.key.as_str() {
"left" => Some(IconName::ArrowLeft), "left" => Some(IconName::ArrowLeft),
"right" => Some(IconName::ArrowRight), "right" => Some(IconName::ArrowRight),
@ -45,6 +45,11 @@ impl KeyBinding {
"escape" => Some(IconName::Escape), "escape" => Some(IconName::Escape),
"pagedown" => Some(IconName::PageDown), "pagedown" => Some(IconName::PageDown),
"pageup" => Some(IconName::PageUp), "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, _ => None,
} }
} }
@ -80,7 +85,7 @@ impl RenderOnce for KeyBinding {
.gap(Spacing::Small.rems(cx)) .gap(Spacing::Small.rems(cx))
.flex_none() .flex_none()
.children(self.key_binding.keystrokes().iter().map(|keystroke| { .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() h_flex()
.flex_none() .flex_none()

View File

@ -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: Each key press is a sequence of modifiers followed by a key. The modifiers are:
- `ctrl-` The control key - `ctrl-` The control key
- `cmd-` On macOS, this is the command key * `cmd-`, `win-` or `super-` for the platform modifier (Command on macOS, Windows key on Windows, and the Super key on Linux).
- `alt-` On macOS, this is the option key - `alt-` for alt (option on macOS)
- `shift-` The shift key - `shift-` The shift key
- `fn-` The function 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: A few examples:
@ -64,10 +64,15 @@ A few examples:
"cmd-k cmd-s": "zed::OpenKeymap", // matches ⌘-k then ⌘-s "cmd-k cmd-s": "zed::OpenKeymap", // matches ⌘-k then ⌘-s
"space e": "editor::Complete", // type space then e "space e": "editor::Complete", // type space then e
"ç": "editor::Complete", // matches ⌥-c "ç": "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 ### Remapping keys