diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 9129cdf31c41ba2effbedecd7dade7e5acbfe301..331c3b602df27e7b5300d2af5e33265bd9ae188d 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -1,3 +1,54 @@ +/// KeyDispatch is where GPUI deals with binding actions to key events. +/// +/// The key pieces to making a key binding work are to define an action, +/// implement a method that takes that action as a type paramater, +/// and then to register the action during render on a focused node +/// with a keymap context: +/// +/// ```rust +/// actions!(editor,[Undo, Redo]);; +/// +/// impl Editor { +/// fn undo(&mut self, _: &Undo, _cx: &mut ViewContext) { ... } +/// fn redo(&mut self, _: &Redo, _cx: &mut ViewContext) { ... } +/// } +/// +/// impl Render for Editor { +/// fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { +/// div() +/// .track_focus(&self.focus_handle) +/// .keymap_context("Editor") +/// .on_action(cx.listener(Editor::undo)) +/// .on_action(cx.listener(Editor::redo)) +/// ... +/// } +/// } +///``` +/// +/// The keybindings themselves are managed independently by calling cx.bind_keys(). +/// (Though mostly when developing Zed itself, you just need to add a new line to +/// assets/keymaps/default.json). +/// +/// ```rust +/// cx.bind_keys([ +/// KeyBinding::new("cmd-z", Editor::undo, Some("Editor")), +/// KeyBinding::new("cmd-shift-z", Editor::redo, Some("Editor")), +/// ]) +/// ``` +/// +/// With all of this in place, GPUI will ensure that if you have an Editor that contains +/// the focus, hitting cmd-z will Undo. +/// +/// In real apps, it is a little more complicated than this, because typically you have +/// several nested views that each register keyboard handlers. In this case action matching +/// bubbles up from the bottom. For example in Zed, the Workspace is the top-level view, which contains Pane's, which contain Editors. If there are conflicting keybindings defined +/// then the Editor's bindings take precedence over the Pane's bindings, which take precedence over the Workspace. +/// +/// In GPUI, keybindings are not limited to just single keystrokes, you can define +/// sequences by separating the keys with a space: +/// +/// KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane")) +/// use crate::{ Action, ActionRegistry, DispatchPhase, ElementContext, EntityId, FocusId, KeyBinding, KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, WindowContext, diff --git a/crates/gpui/src/keymap/matcher.rs b/crates/gpui/src/keymap/matcher.rs index ef875bce38f12b1d1ade89901989a01c4920c58a..09ba281a0d7ebc1e032100eb9463f32767afa403 100644 --- a/crates/gpui/src/keymap/matcher.rs +++ b/crates/gpui/src/keymap/matcher.rs @@ -94,7 +94,7 @@ impl KeystrokeMatcher { /// - KeyMatch::None => No match is valid for this key given any pending keystrokes. /// - KeyMatch::Pending => There exist bindings that is still waiting for more keys. /// - KeyMatch::Some(matches) => One or more bindings have received the necessary key presses. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum KeyMatch { None, Pending, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 5c6c06819637c81ef7293ad86b7079af76cedef6..72113c67386510277edb0840472f667058689ab0 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2,11 +2,11 @@ use crate::{ px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, AsyncWindowContext, AvailableSpace, Bounds, Context, Corners, CursorStyle, DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Flatten, - GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeymatchResult, KeystrokeEvent, - Model, ModelContext, Modifiers, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, - PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptLevel, Render, - ScaledPixels, SharedString, Size, SubscriberSet, Subscription, TaffyLayoutEngine, Task, View, - VisualContext, WeakView, WindowBounds, WindowOptions, + GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeyMatch, KeymatchResult, + Keystroke, KeystrokeEvent, Model, ModelContext, Modifiers, MouseButton, MouseMoveEvent, + MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, + PromptLevel, Render, ScaledPixels, SharedString, Size, SubscriberSet, Subscription, + TaffyLayoutEngine, Task, View, VisualContext, WeakView, WindowBounds, WindowOptions, }; use anyhow::{anyhow, Context as _, Result}; use collections::FxHashSet; @@ -289,12 +289,39 @@ pub struct Window { #[derive(Default, Debug)] struct PendingInput { - text: String, + keystrokes: SmallVec<[Keystroke; 1]>, bindings: SmallVec<[KeyBinding; 1]>, focus: Option, timer: Option>, } +impl PendingInput { + fn is_noop(&self) -> bool { + self.bindings.is_empty() && (self.keystrokes.iter().all(|k| k.ime_key.is_none())) + } + + fn input(&self) -> String { + self.keystrokes + .iter() + .flat_map(|k| k.ime_key.clone()) + .collect::>() + .join("") + } + + fn used_by_binding(&self, binding: &KeyBinding) -> bool { + if self.keystrokes.is_empty() { + return true; + } + let keystroke = &self.keystrokes[0]; + for candidate in keystroke.match_candidates() { + if binding.match_keystrokes(&[candidate]) == KeyMatch::Pending { + return true; + } + } + false + } +} + pub(crate) struct ElementStateBox { pub(crate) inner: Box, pub(crate) parent_view_id: EntityId, @@ -1179,15 +1206,15 @@ impl<'a> WindowContext<'a> { currently_pending = PendingInput::default(); } currently_pending.focus = self.window.focus; - if let Some(new_text) = &key_down_event.keystroke.ime_key.as_ref() { - currently_pending.text += new_text - } + currently_pending + .keystrokes + .push(key_down_event.keystroke.clone()); for binding in bindings { currently_pending.bindings.push(binding); } // for vim compatibility, we also should check "is input handler enabled" - if !currently_pending.text.is_empty() || !currently_pending.bindings.is_empty() { + if !currently_pending.is_noop() { currently_pending.timer = Some(self.spawn(|mut cx| async move { cx.background_executor.timer(Duration::from_secs(1)).await; cx.update(move |cx| { @@ -1199,24 +1226,18 @@ impl<'a> WindowContext<'a> { }) .log_err(); })); - self.window.pending_input = Some(currently_pending); + } else { + currently_pending.timer = None; } + self.window.pending_input = Some(currently_pending); self.propagate_event = false; return; } else if let Some(currently_pending) = self.window.pending_input.take() { - // if you have bound , to one thing, and ,w to another. - // then typing ,i should trigger the comma actions, then the i actions. - // in that scenario "binding.keystrokes" is "i" and "pending.keystrokes" is ",". - // on the other hand if you type ,, it should not trigger the , action. - // in that scenario "binding.keystrokes" is ",w" and "pending.keystrokes" is ",". - - if bindings.iter().all(|binding| { - currently_pending - .bindings - .iter() - .all(|pending| binding.keystrokes().starts_with(&pending.keystrokes)) - }) { + if bindings + .iter() + .all(|binding| !currently_pending.used_by_binding(&binding)) + { self.replay_pending_input(currently_pending) } } @@ -1290,6 +1311,8 @@ impl<'a> WindowContext<'a> { return; } + let input = currently_pending.input(); + self.propagate_event = true; for binding in currently_pending.bindings { self.dispatch_action_on_node(node_id, binding.action.boxed_clone()); @@ -1298,9 +1321,9 @@ impl<'a> WindowContext<'a> { } } - if !currently_pending.text.is_empty() { + if !input.is_empty() { if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { - input_handler.flush_pending_input(¤tly_pending.text, self); + input_handler.flush_pending_input(&input, self); self.window.platform_window.set_input_handler(input_handler) } }