gpui: Add `cx.intercept_keystrokes` API to intercept keystrokes before action dispatch (#34084)

Ben Kunkle created

Closes #ISSUE

`cx.intercept_keystrokes` functions as a sibling API to
`cx.observe_keystrokes`. Under the hood the two API's are basically
identical, however, `cx.observe_keystrokes` runs _after_ all event
dispatch handling (including action dispatch) while
`cx.intercept_keystrokes` runs _before_. This allows for
`cx.stop_propagation()` calls within the `cx.intercept_keystrokes`
callback to prevent action dispatch.

The motivating example usage behind this API is also included in this
PR. It is used as part of a keystroke input component that needs to
intercept keystrokes before action dispatch to display them.

cc: @mikayla-maki 

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

crates/gpui/src/app.rs                | 28 +++++++++++++++++
crates/gpui/src/window.rs             | 33 +++++++++++++++++++
crates/settings_ui/src/keybindings.rs | 47 +++++++++++++++++++---------
3 files changed, 92 insertions(+), 16 deletions(-)

Detailed changes

crates/gpui/src/app.rs 🔗

@@ -272,6 +272,7 @@ pub struct App {
     // TypeId is the type of the event that the listener callback expects
     pub(crate) event_listeners: SubscriberSet<EntityId, (TypeId, Listener)>,
     pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>,
+    pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>,
     pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>,
     pub(crate) release_listeners: SubscriberSet<EntityId, ReleaseListener>,
     pub(crate) global_observers: SubscriberSet<TypeId, Handler>,
@@ -344,6 +345,7 @@ impl App {
                 event_listeners: SubscriberSet::new(),
                 release_listeners: SubscriberSet::new(),
                 keystroke_observers: SubscriberSet::new(),
+                keystroke_interceptors: SubscriberSet::new(),
                 keyboard_layout_observers: SubscriberSet::new(),
                 global_observers: SubscriberSet::new(),
                 quit_observers: SubscriberSet::new(),
@@ -1322,6 +1324,32 @@ impl App {
         )
     }
 
+    /// Register a callback to be invoked when a keystroke is received by the application
+    /// in any window. Note that this fires _before_ all other action and event mechanisms have resolved
+    /// unlike [`App::observe_keystrokes`] which fires after. This means that `cx.stop_propagation` calls
+    /// within interceptors will prevent action dispatch
+    pub fn intercept_keystrokes(
+        &mut self,
+        mut f: impl FnMut(&KeystrokeEvent, &mut Window, &mut App) + 'static,
+    ) -> Subscription {
+        fn inner(
+            keystroke_interceptors: &SubscriberSet<(), KeystrokeObserver>,
+            handler: KeystrokeObserver,
+        ) -> Subscription {
+            let (subscription, activate) = keystroke_interceptors.insert((), handler);
+            activate();
+            subscription
+        }
+
+        inner(
+            &mut self.keystroke_interceptors,
+            Box::new(move |event, window, cx| {
+                f(event, window, cx);
+                true
+            }),
+        )
+    }
+
     /// Register key bindings.
     pub fn bind_keys(&mut self, bindings: impl IntoIterator<Item = KeyBinding>) {
         self.keymap.borrow_mut().add_bindings(bindings);

crates/gpui/src/window.rs 🔗

@@ -1369,6 +1369,31 @@ impl Window {
         });
     }
 
+    pub(crate) fn dispatch_keystroke_interceptors(
+        &mut self,
+        event: &dyn Any,
+        context_stack: Vec<KeyContext>,
+        cx: &mut App,
+    ) {
+        let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() else {
+            return;
+        };
+
+        cx.keystroke_interceptors
+            .clone()
+            .retain(&(), move |callback| {
+                (callback)(
+                    &KeystrokeEvent {
+                        keystroke: key_down_event.keystroke.clone(),
+                        action: None,
+                        context_stack: context_stack.clone(),
+                    },
+                    self,
+                    cx,
+                )
+            });
+    }
+
     /// Schedules the given function to be run at the end of the current effect cycle, allowing entities
     /// that are currently on the stack to be returned to the app.
     pub fn defer(&self, cx: &mut App, f: impl FnOnce(&mut Window, &mut App) + 'static) {
@@ -3522,6 +3547,13 @@ impl Window {
             return;
         };
 
+        cx.propagate_event = true;
+        self.dispatch_keystroke_interceptors(event, self.context_stack(), cx);
+        if !cx.propagate_event {
+            self.finish_dispatch_key_event(event, dispatch_path, self.context_stack(), cx);
+            return;
+        }
+
         let mut currently_pending = self.pending_input.take().unwrap_or_default();
         if currently_pending.focus.is_some() && currently_pending.focus != self.focus {
             currently_pending = PendingInput::default();
@@ -3570,7 +3602,6 @@ impl Window {
             return;
         }
 
-        cx.propagate_event = true;
         for binding in match_result.bindings {
             self.dispatch_action_on_node(node_id, binding.action.as_ref(), cx);
             if !cx.propagate_event {

crates/settings_ui/src/keybindings.rs 🔗

@@ -916,7 +916,7 @@ impl KeybindingEditorModal {
         window: &mut Window,
         cx: &mut App,
     ) -> Self {
-        let keybind_editor = cx.new(KeystrokeInput::new);
+        let keybind_editor = cx.new(|cx| KeystrokeInput::new(window, cx));
 
         let context_editor = cx.new(|cx| {
             let mut editor = Editor::single_line(window, cx);
@@ -1315,14 +1315,22 @@ async fn save_keybinding_update(
 struct KeystrokeInput {
     keystrokes: Vec<Keystroke>,
     focus_handle: FocusHandle,
+    intercept_subscription: Option<Subscription>,
+    _focus_subscriptions: [Subscription; 2],
 }
 
 impl KeystrokeInput {
-    fn new(cx: &mut Context<Self>) -> Self {
+    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
         let focus_handle = cx.focus_handle();
+        let _focus_subscriptions = [
+            cx.on_focus_in(&focus_handle, window, Self::on_focus_in),
+            cx.on_focus_out(&focus_handle, window, Self::on_focus_out),
+        ];
         Self {
             keystrokes: Vec::new(),
             focus_handle,
+            intercept_subscription: None,
+            _focus_subscriptions,
         }
     }
 
@@ -1351,21 +1359,13 @@ impl KeystrokeInput {
         cx.notify();
     }
 
-    fn on_key_down(
-        &mut self,
-        event: &gpui::KeyDownEvent,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if event.is_held {
-            return;
-        }
+    fn handle_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) {
         if let Some(last) = self.keystrokes.last_mut()
             && last.key.is_empty()
         {
-            *last = event.keystroke.clone();
-        } else {
-            self.keystrokes.push(event.keystroke.clone());
+            *last = keystroke.clone();
+        } else if Some(keystroke) != self.keystrokes.last() {
+            self.keystrokes.push(keystroke.clone());
         }
         cx.stop_propagation();
         cx.notify();
@@ -1391,6 +1391,24 @@ impl KeystrokeInput {
         cx.notify();
     }
 
+    fn on_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.intercept_subscription.is_none() {
+            let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, _window, cx| {
+                this.handle_keystroke(&event.keystroke, cx);
+            });
+            self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
+        }
+    }
+
+    fn on_focus_out(
+        &mut self,
+        _event: gpui::FocusOutEvent,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+        self.intercept_subscription.take();
+    }
+
     fn keystrokes(&self) -> &[Keystroke] {
         if self
             .keystrokes
@@ -1418,7 +1436,6 @@ impl Render for KeystrokeInput {
             .id("keybinding_input")
             .track_focus(&self.focus_handle)
             .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
-            .on_key_down(cx.listener(Self::on_key_down))
             .on_key_up(cx.listener(Self::on_key_up))
             .focus(|mut style| {
                 style.border_color = Some(colors.border_focused);