From 5602c481361a09f1b68e8bce66a9ce6dcf0834e7 Mon Sep 17 00:00:00 2001 From: Andrew Lygin Date: Thu, 21 Mar 2024 03:43:31 +0300 Subject: [PATCH] Action release handlers (#8782) 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 --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7d640b4e30..5bd9d58cbc 100644 --- a/assets/keymaps/default-linux.json +++ b/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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ccbb86db22..9264cf2f10 100644 --- a/assets/keymaps/default-macos.json +++ b/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": { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 5f2c874457..2412d1a251 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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>, + init_modifiers: Option, } pub fn init(cx: &mut AppContext) { @@ -94,6 +96,23 @@ impl FileFinder { fn new(delegate: FileFinderDelegate, cx: &mut ViewContext) -> 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, + ) { + 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) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + fn render(&mut self, cx: &mut ViewContext) -> 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, matches: Matches, selected_index: usize, + has_changed_selected_index: bool, cancel_flag: Arc, history_items: Vec, } @@ -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>) { + 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 { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 179cd2ca6a..7098abb963 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/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::(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::(cx) - .unwrap() + .expect("file finder is not open") .read(cx) .picker .clone() diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index d4f7405dc8..1eae5931a0 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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( mut self, @@ -999,6 +1023,9 @@ pub(crate) type KeyDownListener = pub(crate) type KeyUpListener = Box; +pub(crate) type ModifiersChangedListener = + Box; + pub(crate) type ActionListener = Box; /// Construct a new [`Div`] element @@ -1188,6 +1215,7 @@ pub struct Interactivity { pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, + pub(crate) modifiers_changed_listeners: Vec, pub(crate) action_listeners: Vec<(TypeId, ActionListener)>, pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, @@ -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) } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 62392983ee..eabb5691f8 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/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, pub action_listeners: Vec, + pub modifiers_changed_listeners: Vec, pub context: Option, pub focus_id: Option, view_id: Option, @@ -106,6 +108,7 @@ impl ReusedSubtree { } type KeyListener = Rc; +type ModifiersChangedListener = Rc; #[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, 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, diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index d6c4ea28a5..5d39bef3b2 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/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) + } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index fa12861828..247c51ed87 100644 --- a/crates/gpui/src/window.rs +++ b/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::() 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 diff --git a/crates/gpui/src/window/element_cx.rs b/crates/gpui/src/window/element_cx.rs index bc007c5bc8..8b92c316f6 100644 --- a/crates/gpui/src/window/element_cx.rs +++ b/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) + }, + )); + } }