Improve logic for reused bindings, add docs.

Conrad Irwin created

Change summary

crates/gpui/src/key_dispatch.rs   | 51 +++++++++++++++++++++++
crates/gpui/src/keymap/matcher.rs |  2 
crates/gpui/src/window.rs         | 73 +++++++++++++++++++++-----------
3 files changed, 100 insertions(+), 26 deletions(-)

Detailed changes

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<Self>) { ... }
+///   fn redo(&mut self, _: &Redo, _cx: &mut ViewContext<Self>) { ... }
+/// }
+///
+/// impl Render for Editor {
+///   fn render(&mut self, cx: &mut ViewContext<Self>) -> 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,

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,

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<FocusId>,
     timer: Option<Task<()>>,
 }
 
+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::<Vec<String>>()
+            .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<dyn Any>,
     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(&currently_pending.text, self);
+                input_handler.flush_pending_input(&input, self);
                 self.window.platform_window.set_input_handler(input_handler)
             }
         }