Action release handlers (#8782)

Andrew Lygin and @ConradIrwin created

This PR adds support for handling action releases — events that
are fired when the user releases all the modifier keys that were part of
an action-triggering shortcut.

If the user holds modifiers and invokes several actions sequentially via
shortcuts (same or different), only the last action is "released" when
its modifier keys released.

~The following methods were added to `Div`:~
- ~`capture_action_release()`~
- ~`on_action_release()`~
- ~`on_boxed_action_release()`~

~They work similarly to `capture_action()`, `on_action()` and
`on_boxed_action()`.~

See the implementation details in [this
comment](https://github.com/zed-industries/zed/pull/8782#issuecomment-2009154646).

Release Notes:

- Added a fast-switch mode to the file finder: hit `p` or `shift-p`
while holding down `cmd` to select a file immediately. (#8258).

Related Issues:

- Implements #8757 
- Implements #8258
- Part of #7653 

Co-authored-by: @ConradIrwin

Change summary

assets/keymaps/default-linux.json           |   5 
assets/keymaps/default-macos.json           |   5 
crates/file_finder/src/file_finder.rs       |  36 +++++
crates/file_finder/src/file_finder_tests.rs | 119 ++++++++++++++++++++--
crates/gpui/src/elements/div.rs             |  43 +++++++
crates/gpui/src/key_dispatch.rs             |  12 ++
crates/gpui/src/platform/keystroke.rs       |   9 +
crates/gpui/src/window.rs                   |  35 +++++
crates/gpui/src/window/element_cx.rs        |  27 ++++
9 files changed, 260 insertions(+), 31 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -15,6 +15,7 @@
       "shift-f10": "menu::ShowContextMenu",
       "ctrl-enter": "menu::SecondaryConfirm",
       "escape": "menu::Cancel",
+      "ctrl-escape": "menu::Cancel",
       "ctrl-c": "menu::Cancel",
       "shift-enter": "menu::UseSelectedQuery",
       "ctrl-shift-w": "workspace::CloseWindow",
@@ -558,6 +559,10 @@
       "escape": "chat_panel::CloseReplyPreview"
     }
   },
+  {
+    "context": "FileFinder",
+    "bindings": { "ctrl-shift-p": "menu::SelectPrev" }
+  },
   {
     "context": "Terminal",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -16,6 +16,7 @@
       "ctrl-enter": "menu::ShowContextMenu",
       "cmd-enter": "menu::SecondaryConfirm",
       "escape": "menu::Cancel",
+      "cmd-escape": "menu::Cancel",
       "ctrl-c": "menu::Cancel",
       "shift-enter": "menu::UseSelectedQuery",
       "cmd-shift-w": "workspace::CloseWindow",
@@ -597,6 +598,10 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "FileFinder",
+    "bindings": { "cmd-shift-p": "menu::SelectPrev" }
+  },
   {
     "context": "Terminal",
     "bindings": {

crates/file_finder/src/file_finder.rs 🔗

@@ -5,8 +5,9 @@ use collections::{HashMap, HashSet};
 use editor::{scroll::Autoscroll, Bias, Editor};
 use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
 use gpui::{
-    actions, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
-    ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
+    actions, rems, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
+    Model, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, View,
+    ViewContext, VisualContext, WeakView,
 };
 use itertools::Itertools;
 use picker::{Picker, PickerDelegate};
@@ -30,6 +31,7 @@ impl ModalView for FileFinder {}
 
 pub struct FileFinder {
     picker: View<Picker<FileFinderDelegate>>,
+    init_modifiers: Option<Modifiers>,
 }
 
 pub fn init(cx: &mut AppContext) {
@@ -94,6 +96,23 @@ impl FileFinder {
     fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
         Self {
             picker: cx.new_view(|cx| Picker::uniform_list(delegate, cx)),
+            init_modifiers: cx.modifiers().modified().then_some(cx.modifiers()),
+        }
+    }
+
+    fn handle_modifiers_changed(
+        &mut self,
+        event: &ModifiersChangedEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let Some(init_modifiers) = self.init_modifiers else {
+            return;
+        };
+        if self.picker.read(cx).delegate.has_changed_selected_index {
+            if !event.modified() || !init_modifiers.is_subset_of(&event) {
+                self.init_modifiers = None;
+                cx.dispatch_action(menu::Confirm.boxed_clone());
+            }
         }
     }
 }
@@ -107,8 +126,12 @@ impl FocusableView for FileFinder {
 }
 
 impl Render for FileFinder {
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
-        v_flex().w(rems(34.)).child(self.picker.clone())
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("FileFinder")
+            .w(rems(34.))
+            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+            .child(self.picker.clone())
     }
 }
 
@@ -123,6 +146,7 @@ pub struct FileFinderDelegate {
     currently_opened_path: Option<FoundPath>,
     matches: Matches,
     selected_index: usize,
+    has_changed_selected_index: bool,
     cancel_flag: Arc<AtomicBool>,
     history_items: Vec<FoundPath>,
 }
@@ -376,6 +400,7 @@ impl FileFinderDelegate {
             latest_search_query: None,
             currently_opened_path,
             matches: Matches::default(),
+            has_changed_selected_index: false,
             selected_index: 0,
             cancel_flag: Arc::new(AtomicBool::new(false)),
             history_items,
@@ -683,6 +708,7 @@ impl PickerDelegate for FileFinderDelegate {
     }
 
     fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
+        self.has_changed_selected_index = true;
         self.selected_index = ix;
         cx.notify();
     }
@@ -721,7 +747,7 @@ impl PickerDelegate for FileFinderDelegate {
                 }),
             );
 
-            self.selected_index = self.calculate_selected_index();
+            self.selected_index = 0;
             cx.notify();
             Task::ready(())
         } else {

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -872,7 +872,6 @@ async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
 
     // generate some history to select from
     open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
-    cx.executor().run_until_parked();
     open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
     open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
     let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
@@ -1125,12 +1124,12 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
     let picker = open_file_picker(&workspace, cx);
     picker.update(cx, |finder, _| {
         assert_eq!(finder.delegate.matches.len(), 3);
-        assert_match_at_position(finder, 0, "main.rs");
-        assert_match_selection(finder, 1, "lib.rs");
+        assert_match_selection(finder, 0, "main.rs");
+        assert_match_at_position(finder, 1, "lib.rs");
         assert_match_at_position(finder, 2, "bar.rs");
     });
 
-    // all files match, main.rs is still on top
+    // all files match, main.rs is still on top, but the second item is selected
     picker
         .update(cx, |finder, cx| {
             finder.delegate.update_matches(".rs".to_string(), cx)
@@ -1173,8 +1172,8 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         .await;
     picker.update(cx, |finder, _| {
         assert_eq!(finder.delegate.matches.len(), 3);
-        assert_match_at_position(finder, 0, "main.rs");
-        assert_match_selection(finder, 1, "lib.rs");
+        assert_match_selection(finder, 0, "main.rs");
+        assert_match_at_position(finder, 1, "lib.rs");
     });
 }
 
@@ -1207,29 +1206,31 @@ async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
     let picker = open_file_picker(&workspace, cx);
     picker.update(cx, |finder, _| {
         assert_eq!(finder.delegate.matches.len(), 3);
-        assert_match_at_position(finder, 0, "3.txt");
-        assert_match_selection(finder, 1, "2.txt");
+        assert_match_selection(finder, 0, "3.txt");
+        assert_match_at_position(finder, 1, "2.txt");
         assert_match_at_position(finder, 2, "1.txt");
     });
 
+    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm); // Open 2.txt
 
     let picker = open_file_picker(&workspace, cx);
     picker.update(cx, |finder, _| {
         assert_eq!(finder.delegate.matches.len(), 3);
-        assert_match_at_position(finder, 0, "2.txt");
-        assert_match_selection(finder, 1, "3.txt");
+        assert_match_selection(finder, 0, "2.txt");
+        assert_match_at_position(finder, 1, "3.txt");
         assert_match_at_position(finder, 2, "1.txt");
     });
 
+    cx.dispatch_action(SelectNext);
     cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm); // Open 1.txt
 
     let picker = open_file_picker(&workspace, cx);
     picker.update(cx, |finder, _| {
         assert_eq!(finder.delegate.matches.len(), 3);
-        assert_match_at_position(finder, 0, "1.txt");
-        assert_match_selection(finder, 1, "2.txt");
+        assert_match_selection(finder, 0, "1.txt");
+        assert_match_at_position(finder, 1, "2.txt");
         assert_match_at_position(finder, 2, "3.txt");
     });
 }
@@ -1469,6 +1470,98 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
     });
 }
 
+#[gpui::test]
+async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            "/test",
+            json!({
+                "1.txt": "// One",
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
+    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+
+    open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
+
+    cx.simulate_modifiers_change(Modifiers::command());
+    open_file_picker(&workspace, cx);
+
+    cx.simulate_modifiers_change(Modifiers::none());
+    active_file_picker(&workspace, cx);
+}
+
+#[gpui::test]
+async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            "/test",
+            json!({
+                "1.txt": "// One",
+                "2.txt": "// Two",
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
+    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+
+    open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
+    open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
+
+    cx.simulate_modifiers_change(Modifiers::command());
+    let picker = open_file_picker(&workspace, cx);
+    picker.update(cx, |finder, _| {
+        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_match_selection(finder, 0, "2.txt");
+        assert_match_at_position(finder, 1, "1.txt");
+    });
+
+    cx.dispatch_action(SelectNext);
+    cx.simulate_modifiers_change(Modifiers::none());
+    cx.read(|cx| {
+        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
+        assert_eq!(active_editor.read(cx).title(cx), "1.txt");
+    });
+}
+
+#[gpui::test]
+async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::TestAppContext) {
+    let app_state = init_test(cx);
+
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            "/test",
+            json!({
+                "1.txt": "// One",
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
+    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+
+    open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
+
+    cx.simulate_modifiers_change(Modifiers::command());
+    open_file_picker(&workspace, cx);
+
+    cx.simulate_modifiers_change(Modifiers::command_shift());
+    active_file_picker(&workspace, cx);
+}
+
 async fn open_close_queried_buffer(
     input: &str,
     expected_matches: usize,
@@ -1581,7 +1674,7 @@ fn active_file_picker(
     workspace.update(cx, |workspace, cx| {
         workspace
             .active_modal::<FileFinder>(cx)
-            .unwrap()
+            .expect("file finder is not open")
             .read(cx)
             .picker
             .clone()

crates/gpui/src/elements/div.rs 🔗

@@ -18,10 +18,10 @@
 use crate::{
     point, px, size, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, AppContext, Bounds,
     ClickEvent, DispatchPhase, Element, ElementContext, ElementId, FocusHandle, Global, Hitbox,
-    HitboxId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, MouseButton,
-    MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render,
-    ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, View, Visibility,
-    WindowContext,
+    HitboxId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId,
+    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
+    ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
+    StyleRefinement, Styled, Task, View, Visibility, WindowContext,
 };
 use collections::HashMap;
 use refineable::Refineable;
@@ -389,6 +389,18 @@ impl Interactivity {
             }));
     }
 
+    /// Bind the given callback to modifiers changing events.
+    /// The imperative API equivalent to [`InteractiveElement::on_modifiers_changed`]
+    ///
+    /// See [`ViewContext::listener`](crate::ViewContext::listener) to get access to a view's state from this callback.
+    pub fn on_modifiers_changed(
+        &mut self,
+        listener: impl Fn(&ModifiersChangedEvent, &mut WindowContext) + 'static,
+    ) {
+        self.modifiers_changed_listeners
+            .push(Box::new(move |event, cx| listener(event, cx)));
+    }
+
     /// Bind the given callback to drop events of the given type, whether or not the drag started on this element
     /// The imperative API equivalent to [`InteractiveElement::on_drop`]
     ///
@@ -775,6 +787,18 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Bind the given callback to modifiers changing events.
+    /// The fluent API equivalent to [`Interactivity::on_modifiers_changed`]
+    ///
+    /// See [`ViewContext::listener`](crate::ViewContext::listener) to get access to a view's state from this callback.
+    fn on_modifiers_changed(
+        mut self,
+        listener: impl Fn(&ModifiersChangedEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.interactivity().on_modifiers_changed(listener);
+        self
+    }
+
     /// Apply the given style when the given data type is dragged over this element
     fn drag_over<S: 'static>(
         mut self,
@@ -999,6 +1023,9 @@ pub(crate) type KeyDownListener =
 pub(crate) type KeyUpListener =
     Box<dyn Fn(&KeyUpEvent, DispatchPhase, &mut WindowContext) + 'static>;
 
+pub(crate) type ModifiersChangedListener =
+    Box<dyn Fn(&ModifiersChangedEvent, &mut WindowContext) + 'static>;
+
 pub(crate) type ActionListener = Box<dyn Fn(&dyn Any, DispatchPhase, &mut WindowContext) + 'static>;
 
 /// Construct a new [`Div`] element
@@ -1188,6 +1215,7 @@ pub struct Interactivity {
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
     pub(crate) key_up_listeners: Vec<KeyUpListener>,
+    pub(crate) modifiers_changed_listeners: Vec<ModifiersChangedListener>,
     pub(crate) action_listeners: Vec<(TypeId, ActionListener)>,
     pub(crate) drop_listeners: Vec<(TypeId, DropListener)>,
     pub(crate) can_drop_predicate: Option<CanDropPredicate>,
@@ -1873,6 +1901,7 @@ impl Interactivity {
     fn paint_keyboard_listeners(&mut self, cx: &mut ElementContext) {
         let key_down_listeners = mem::take(&mut self.key_down_listeners);
         let key_up_listeners = mem::take(&mut self.key_up_listeners);
+        let modifiers_changed_listeners = mem::take(&mut self.modifiers_changed_listeners);
         let action_listeners = mem::take(&mut self.action_listeners);
         if let Some(context) = self.key_context.clone() {
             cx.set_key_context(context);
@@ -1893,6 +1922,12 @@ impl Interactivity {
             })
         }
 
+        for listener in modifiers_changed_listeners {
+            cx.on_modifiers_changed(move |event: &ModifiersChangedEvent, cx| {
+                listener(event, cx);
+            })
+        }
+
         for (action_type, listener) in action_listeners {
             cx.on_action(action_type, listener)
         }

crates/gpui/src/key_dispatch.rs 🔗

@@ -51,7 +51,8 @@
 ///
 use crate::{
     Action, ActionRegistry, DispatchPhase, ElementContext, EntityId, FocusId, KeyBinding,
-    KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, WindowContext,
+    KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, ModifiersChangedEvent,
+    WindowContext,
 };
 use collections::FxHashMap;
 use smallvec::SmallVec;
@@ -82,6 +83,7 @@ pub(crate) struct DispatchTree {
 pub(crate) struct DispatchNode {
     pub key_listeners: Vec<KeyListener>,
     pub action_listeners: Vec<DispatchActionListener>,
+    pub modifiers_changed_listeners: Vec<ModifiersChangedListener>,
     pub context: Option<KeyContext>,
     pub focus_id: Option<FocusId>,
     view_id: Option<EntityId>,
@@ -106,6 +108,7 @@ impl ReusedSubtree {
 }
 
 type KeyListener = Rc<dyn Fn(&dyn Any, DispatchPhase, &mut ElementContext)>;
+type ModifiersChangedListener = Rc<dyn Fn(&ModifiersChangedEvent, &mut ElementContext)>;
 
 #[derive(Clone)]
 pub(crate) struct DispatchActionListener {
@@ -241,6 +244,7 @@ impl DispatchTree {
         let target = self.active_node();
         target.key_listeners = mem::take(&mut source.key_listeners);
         target.action_listeners = mem::take(&mut source.action_listeners);
+        target.modifiers_changed_listeners = mem::take(&mut source.modifiers_changed_listeners);
     }
 
     pub fn reuse_subtree(&mut self, old_range: Range<usize>, source: &mut Self) -> ReusedSubtree {
@@ -310,6 +314,12 @@ impl DispatchTree {
         self.active_node().key_listeners.push(listener);
     }
 
+    pub fn on_modifiers_changed(&mut self, listener: ModifiersChangedListener) {
+        self.active_node()
+            .modifiers_changed_listeners
+            .push(listener);
+    }
+
     pub fn on_action(
         &mut self,
         action_type: TypeId,

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

@@ -229,4 +229,13 @@ impl Modifiers {
             ..Default::default()
         }
     }
+
+    /// Checks if this Modifiers is a subset of another Modifiers
+    pub fn is_subset_of(&self, other: &Modifiers) -> bool {
+        (other.control || !self.control)
+            && (other.alt || !self.alt)
+            && (other.shift || !self.shift)
+            && (other.command || !self.command)
+            && (other.function || !self.function)
+    }
 }

crates/gpui/src/window.rs 🔗

@@ -4,10 +4,11 @@ use crate::{
     DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter,
     FileDropEvent, Flatten, Global, GlobalElementId, GlobalPixels, Hsla, KeyBinding, 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, TextStyle, TextStyleRefinement, View,
-    VisualContext, WeakView, WindowAppearance, WindowOptions, WindowParams, WindowTextSystem,
+    ModifiersChangedEvent, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas,
+    PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptLevel, Render, ScaledPixels,
+    SharedString, Size, SubscriberSet, Subscription, TaffyLayoutEngine, Task, TextStyle,
+    TextStyleRefinement, View, VisualContext, WeakView, WindowAppearance, WindowOptions,
+    WindowParams, WindowTextSystem,
 };
 use anyhow::{anyhow, Context as _, Result};
 use collections::FxHashSet;
@@ -1381,6 +1382,11 @@ impl<'a> WindowContext<'a> {
             return;
         }
 
+        self.dispatch_modifiers_changed_event(event, &dispatch_path);
+        if !self.propagate_event {
+            return;
+        }
+
         self.dispatch_keystroke_observers(event, None);
     }
 
@@ -1418,6 +1424,27 @@ impl<'a> WindowContext<'a> {
         }
     }
 
+    fn dispatch_modifiers_changed_event(
+        &mut self,
+        event: &dyn Any,
+        dispatch_path: &SmallVec<[DispatchNodeId; 32]>,
+    ) {
+        let Some(event) = event.downcast_ref::<ModifiersChangedEvent>() else {
+            return;
+        };
+        for node_id in dispatch_path.iter().rev() {
+            let node = self.window.rendered_frame.dispatch_tree.node(*node_id);
+            for listener in node.modifiers_changed_listeners.clone() {
+                self.with_element_context(|cx| {
+                    listener(event, cx);
+                });
+                if !self.propagate_event {
+                    return;
+                }
+            }
+        }
+    }
+
     /// Determine whether a potential multi-stroke key binding is in progress on this window.
     pub fn has_pending_keystrokes(&self) -> bool {
         self.window

crates/gpui/src/window/element_cx.rs 🔗

@@ -33,10 +33,11 @@ use crate::{
     ContentMask, Corners, CursorStyle, DevicePixels, DispatchNodeId, DispatchPhase, DispatchTree,
     DrawPhase, ElementId, ElementStateBox, EntityId, FocusHandle, FocusId, FontId, GlobalElementId,
     GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyContext, KeyEvent, LayoutId,
-    LineLayoutIndex, MonochromeSprite, MouseEvent, PaintQuad, Path, Pixels, PlatformInputHandler,
-    Point, PolychromeSprite, Quad, RenderGlyphParams, RenderImageParams, RenderSvgParams, Scene,
-    Shadow, SharedString, Size, StrikethroughStyle, Style, TextStyleRefinement,
-    TransformationMatrix, Underline, UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS,
+    LineLayoutIndex, ModifiersChangedEvent, MonochromeSprite, MouseEvent, PaintQuad, Path, Pixels,
+    PlatformInputHandler, Point, PolychromeSprite, Quad, RenderGlyphParams, RenderImageParams,
+    RenderSvgParams, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style,
+    TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, Window, WindowContext,
+    SUBPIXEL_VARIANTS,
 };
 
 pub(crate) type AnyMouseListener =
@@ -1324,4 +1325,22 @@ impl<'a> ElementContext<'a> {
             },
         ));
     }
+
+    /// Register a modifiers changed event listener on the window for the next frame.
+    ///
+    /// This is a fairly low-level method, so prefer using event handlers on elements unless you have
+    /// a specific need to register a global listener.
+    pub fn on_modifiers_changed(
+        &mut self,
+        listener: impl Fn(&ModifiersChangedEvent, &mut ElementContext) + 'static,
+    ) {
+        self.window
+            .next_frame
+            .dispatch_tree
+            .on_modifiers_changed(Rc::new(
+                move |event: &ModifiersChangedEvent, cx: &mut ElementContext<'_>| {
+                    listener(event, cx)
+                },
+            ));
+    }
 }