From 8a73bc4c7dc8753e47b0e7f6f22c50dfa6937031 Mon Sep 17 00:00:00 2001 From: N <47500890+avi-cenna@users.noreply.github.com> Date: Tue, 20 Feb 2024 17:01:45 -0500 Subject: [PATCH] Vim: enable sending multiple keystrokes from custom keybinding (#7965) Release Notes: - Added `workspace::SendKeystrokes` to enable mapping from one key to a sequence of others ([#7033](https://github.com/zed-industries/zed/issues/7033)). Improves #7033. Big thank you to @ConradIrwin who did most of the heavy lifting on this one. This PR allows the user to send multiple keystrokes via custom keybinding. For example, the following keybinding would go down four lines and then right four characters. ```json [ { "context": "Editor && VimControl && !VimWaiting && !menu", "bindings": { "g z": [ "workspace::SendKeystrokes", "j j j j l l l l" ], } } ] ``` --------- Co-authored-by: Conrad Irwin --- Cargo.lock | 1 + crates/command_palette/Cargo.toml | 1 + crates/command_palette/src/command_palette.rs | 234 +++++++++++------- crates/editor/src/test/editor_test_context.rs | 2 +- crates/gpui/src/action.rs | 2 +- crates/gpui/src/app/test_context.rs | 15 +- crates/gpui/src/interactive.rs | 4 +- crates/gpui/src/platform.rs | 2 +- crates/gpui/src/platform/test/window.rs | 41 +-- crates/gpui/src/window.rs | 36 ++- crates/picker/src/picker.rs | 31 ++- crates/vim/src/test.rs | 67 +++++ crates/workspace/src/workspace.rs | 60 ++++- 13 files changed, 341 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98c39f56b237e04f4eca30da6ffdb7946f32cb77..43efeab533a77291bbdd1a9bf3a492212f01800a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2183,6 +2183,7 @@ dependencies = [ "language", "menu", "picker", + "postage", "project", "release_channel", "serde", diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index cf115027411a41e073f05535fbd66de17c28c793..f95525542e352016b092649108530586f211974e 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -28,6 +28,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +postage.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 2a7a94b5444b87a751624162763a43932961f492..e7edf393ffa59cc59d1d081bda15861c2cf4762c 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -1,6 +1,7 @@ use std::{ cmp::{self, Reverse}, sync::Arc, + time::Duration, }; use client::telemetry::Telemetry; @@ -9,11 +10,12 @@ use copilot::CommandPaletteFilter; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global, - ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, + ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; -use release_channel::{parse_zed_link, ReleaseChannel}; +use postage::{sink::Sink, stream::Stream}; +use release_channel::parse_zed_link; use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{ModalView, Workspace}; @@ -119,6 +121,10 @@ pub struct CommandPaletteDelegate { selected_ix: usize, telemetry: Arc, previous_focus_handle: FocusHandle, + updating_matches: Option<( + Task<()>, + postage::dispatch::Receiver<(Vec, Vec)>, + )>, } struct Command { @@ -138,7 +144,7 @@ impl Clone for Command { /// Hit count for each command in the palette. /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because /// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it. -#[derive(Default)] +#[derive(Default, Clone)] struct HitCounts(HashMap); impl Global for HitCounts {} @@ -158,6 +164,66 @@ impl CommandPaletteDelegate { selected_ix: 0, telemetry, previous_focus_handle, + updating_matches: None, + } + } + + fn matches_updated( + &mut self, + query: String, + mut commands: Vec, + mut matches: Vec, + cx: &mut ViewContext>, + ) { + self.updating_matches.take(); + + let mut intercept_result = + if let Some(interceptor) = cx.try_global::() { + (interceptor.0)(&query, cx) + } else { + None + }; + + if parse_zed_link(&query).is_some() { + intercept_result = Some(CommandInterceptResult { + action: OpenZedUrl { url: query.clone() }.boxed_clone(), + string: query.clone(), + positions: vec![], + }) + } + + if let Some(CommandInterceptResult { + action, + string, + positions, + }) = intercept_result + { + if let Some(idx) = matches + .iter() + .position(|m| commands[m.candidate_id].action.type_id() == action.type_id()) + { + matches.remove(idx); + } + commands.push(Command { + name: string.clone(), + action, + }); + matches.insert( + 0, + StringMatch { + candidate_id: commands.len() - 1, + string, + positions, + score: 0.0, + }, + ) + } + self.commands = commands; + self.matches = matches; + if self.matches.is_empty() { + self.selected_ix = 0; + } else { + self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1); } } } @@ -186,113 +252,99 @@ impl PickerDelegate for CommandPaletteDelegate { query: String, cx: &mut ViewContext>, ) -> gpui::Task<()> { - let mut commands = self.all_commands.clone(); - - cx.spawn(move |picker, mut cx| async move { - cx.read_global::(|hit_counts, _| { + let (mut tx, mut rx) = postage::dispatch::channel(1); + let task = cx.background_executor().spawn({ + let mut commands = self.all_commands.clone(); + let hit_counts = cx.global::().clone(); + let executor = cx.background_executor().clone(); + let query = query.clone(); + async move { commands.sort_by_key(|action| { ( Reverse(hit_counts.0.get(&action.name).cloned()), action.name.clone(), ) }); - }) - .ok(); - let candidates = commands - .iter() - .enumerate() - .map(|(ix, command)| StringMatchCandidate { - id: ix, - string: command.name.to_string(), - char_bag: command.name.chars().collect(), - }) - .collect::>(); - let mut matches = if query.is_empty() { - candidates - .into_iter() + let candidates = commands + .iter() .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, + .map(|(ix, command)| StringMatchCandidate { + id: ix, + string: command.name.to_string(), + char_bag: command.name.chars().collect(), }) - .collect() - } else { - fuzzy::match_strings( - &candidates, - &query, - true, - 10000, - &Default::default(), - cx.background_executor().clone(), - ) - .await - }; + .collect::>(); + + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + let ret = fuzzy::match_strings( + &candidates, + &query, + true, + 10000, + &Default::default(), + executor, + ) + .await; + ret + }; - let mut intercept_result = cx - .try_read_global(|interceptor: &CommandPaletteInterceptor, cx| { - (interceptor.0)(&query, cx) - }) - .flatten(); - let release_channel = cx - .update(|cx| ReleaseChannel::try_global(cx)) - .ok() - .flatten(); - if release_channel == Some(ReleaseChannel::Dev) { - if parse_zed_link(&query).is_some() { - intercept_result = Some(CommandInterceptResult { - action: OpenZedUrl { url: query.clone() }.boxed_clone(), - string: query.clone(), - positions: vec![], - }) - } + tx.send((commands, matches)).await.log_err(); } + }); + self.updating_matches = Some((task, rx.clone())); - if let Some(CommandInterceptResult { - action, - string, - positions, - }) = intercept_result - { - if let Some(idx) = matches - .iter() - .position(|m| commands[m.candidate_id].action.type_id() == action.type_id()) - { - matches.remove(idx); - } - commands.push(Command { - name: string.clone(), - action, - }); - matches.insert( - 0, - StringMatch { - candidate_id: commands.len() - 1, - string, - positions, - score: 0.0, - }, - ) - } + cx.spawn(move |picker, mut cx| async move { + let Some((commands, matches)) = rx.recv().await else { + return; + }; picker - .update(&mut cx, |picker, _| { - let delegate = &mut picker.delegate; - delegate.commands = commands; - delegate.matches = matches; - if delegate.matches.is_empty() { - delegate.selected_ix = 0; - } else { - delegate.selected_ix = - cmp::min(delegate.selected_ix, delegate.matches.len() - 1); - } + .update(&mut cx, |picker, cx| { + picker + .delegate + .matches_updated(query, commands, matches, cx) }) .log_err(); }) } + fn finalize_update_matches( + &mut self, + query: String, + duration: Duration, + cx: &mut ViewContext>, + ) -> bool { + let Some((task, rx)) = self.updating_matches.take() else { + return true; + }; + + match cx + .background_executor() + .block_with_timeout(duration, rx.clone().recv()) + { + Ok(Some((commands, matches))) => { + self.matches_updated(query, commands, matches, cx); + true + } + _ => { + self.updating_matches = Some((task, rx)); + false + } + } + } + fn dismissed(&mut self, cx: &mut ViewContext>) { self.command_palette .update(cx, |_, cx| cx.emit(DismissEvent)) diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 9ad08390884a1c9d4e7a16c2c91585eebd7ffe06..11ee49ac9583a52aadc73533bb9e794c9f68144e 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -147,7 +147,7 @@ impl EditorTestContext { self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text)); let keystroke = Keystroke::parse(keystroke_text).unwrap(); - self.cx.dispatch_keystroke(self.window, keystroke, false); + self.cx.dispatch_keystroke(self.window, keystroke); keystroke_under_test_handle } diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index c6ea705b570167d3c994cc821967bdee6df1f7f6..44e6ec17fff781a7da52080fa03765a0dbaff1c6 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -39,7 +39,7 @@ use std::any::{Any, TypeId}; /// } /// register_action!(Paste); /// ``` -pub trait Action: 'static { +pub trait Action: 'static + Send { /// Clone the action into a new box fn boxed_clone(&self) -> Box; diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0f64a0690fc0e42f4a7aba3d0ed30c0c4058e5a3..a3e1eda0569d36887ca530262f759b7e93fd5647 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -335,7 +335,7 @@ impl TestAppContext { .map(Keystroke::parse) .map(Result::unwrap) { - self.dispatch_keystroke(window, keystroke.into(), false); + self.dispatch_keystroke(window, keystroke.into()); } self.background_executor.run_until_parked() @@ -347,21 +347,16 @@ impl TestAppContext { /// This will also run the background executor until it's parked. pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) { - self.dispatch_keystroke(window, keystroke.into(), false); + self.dispatch_keystroke(window, keystroke.into()); } self.background_executor.run_until_parked() } /// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`) - pub fn dispatch_keystroke( - &mut self, - window: AnyWindowHandle, - keystroke: Keystroke, - is_held: bool, - ) { - self.test_window(window) - .simulate_keystroke(keystroke, is_held) + pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) { + self.update_window(window, |_, cx| cx.dispatch_keystroke(keystroke)) + .unwrap(); } /// Returns the `TestWindow` backing the given handle. diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 1bc32717c4b7702ca37ee413092d13e12fc6114f..69798abe28d411317b373ccb7b69c7d5ffb43982 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -491,8 +491,8 @@ mod test { .update(cx, |test_view, cx| cx.focus(&test_view.focus_handle)) .unwrap(); - cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap(), false); - cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap(), false); + cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap()); + cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap()); window .update(cx, |test_view, _| { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index de39ef61cc981dae8356423048c6fc6bbcc9d99a..7e57009af217dd616c7aa8b48d29606fbe77ca77 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -412,7 +412,7 @@ impl PlatformInputHandler { .flatten() } - pub(crate) fn flush_pending_input(&mut self, input: &str, cx: &mut WindowContext) { + pub(crate) fn dispatch_input(&mut self, input: &str, cx: &mut WindowContext) { self.handler.replace_text_in_range(None, input, cx); } } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 940a3e44c5724ab8fca4d54c0c90fc47532fc0fd..49ce7bd771a864985466de25d18cfc3da02d10ae 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,7 +1,7 @@ use crate::{ - px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, + px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, Pixels, PlatformAtlas, + PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, Size, + TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, }; use collections::HashMap; use parking_lot::Mutex; @@ -112,41 +112,6 @@ impl TestWindow { self.0.lock().input_callback = Some(callback); result } - - pub fn simulate_keystroke(&mut self, mut keystroke: Keystroke, is_held: bool) { - if keystroke.ime_key.is_none() - && !keystroke.modifiers.command - && !keystroke.modifiers.control - && !keystroke.modifiers.function - { - keystroke.ime_key = Some(if keystroke.modifiers.shift { - keystroke.key.to_ascii_uppercase().clone() - } else { - keystroke.key.clone() - }) - } - - if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held, - })) { - return; - } - - let mut lock = self.0.lock(); - let Some(mut input_handler) = lock.input_handler.take() else { - panic!( - "simulate_keystroke {:?} input event was not handled and there was no active input", - &keystroke - ); - }; - drop(lock); - if let Some(text) = keystroke.ime_key.as_ref() { - input_handler.replace_text_in_range(None, &text); - } - - self.0.lock().input_handler = Some(input_handler); - } } impl PlatformWindow for TestWindow { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 351ab15bbaac056c3ee928b9eb932d95462a500c..47cab51910833767992c050dfb3c540432f1e831 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -950,7 +950,7 @@ impl<'a> WindowContext<'a> { /// Produces a new frame and assigns it to `rendered_frame`. To actually show /// the contents of the new [Scene], use [present]. - pub(crate) fn draw(&mut self) { + pub fn draw(&mut self) { self.window.dirty.set(false); self.window.drawing = true; @@ -1099,6 +1099,38 @@ impl<'a> WindowContext<'a> { self.window.needs_present.set(false); } + /// Dispatch a given keystroke as though the user had typed it. + /// You can create a keystroke with Keystroke::parse(""). + pub fn dispatch_keystroke(&mut self, mut keystroke: Keystroke) -> bool { + if keystroke.ime_key.is_none() + && !keystroke.modifiers.command + && !keystroke.modifiers.control + && !keystroke.modifiers.function + { + keystroke.ime_key = Some(if keystroke.modifiers.shift { + keystroke.key.to_uppercase().clone() + } else { + keystroke.key.clone() + }) + } + if self.dispatch_event(PlatformInput::KeyDown(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + })) { + return true; + } + + if let Some(input) = keystroke.ime_key { + if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { + input_handler.dispatch_input(&input, self); + self.window.platform_window.set_input_handler(input_handler); + return true; + } + } + + false + } + /// Dispatch a mouse or keyboard event on the window. pub fn dispatch_event(&mut self, event: PlatformInput) -> bool { self.window.last_input_timestamp.set(Instant::now()); @@ -1423,7 +1455,7 @@ impl<'a> WindowContext<'a> { if !input.is_empty() { if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { - input_handler.flush_pending_input(&input, self); + input_handler.dispatch_input(&input, self); self.window.platform_window.set_input_handler(input_handler) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 09019a8434296bc299e2458b12660703bdbc8e70..32add0b5098a3dc3a74dd107f5516e40fb5dd39d 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -4,7 +4,7 @@ use gpui::{ FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, }; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing}; use workspace::ModalView; @@ -40,6 +40,19 @@ pub trait PickerDelegate: Sized + 'static { fn placeholder_text(&self) -> Arc; fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()>; + // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background + // work for up to `duration` to try and get a result synchronously. + // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes + // mostly work when dismissing a palette. + fn finalize_update_matches( + &mut self, + _query: String, + _duration: Duration, + _cx: &mut ViewContext>, + ) -> bool { + false + } + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); fn dismissed(&mut self, cx: &mut ViewContext>); @@ -98,6 +111,9 @@ impl Picker { is_modal: true, }; this.update_matches("".to_string(), cx); + // give the delegate 4ms to renderthe first set of suggestions. + this.delegate + .finalize_update_matches("".to_string(), Duration::from_millis(4), cx); this } @@ -197,15 +213,24 @@ impl Picker { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - if self.pending_update_matches.is_some() { + if self.pending_update_matches.is_some() + && !self + .delegate + .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx) + { self.confirm_on_update = Some(false) } else { + self.pending_update_matches.take(); self.delegate.confirm(false, cx); } } fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { - if self.pending_update_matches.is_some() { + if self.pending_update_matches.is_some() + && !self + .delegate + .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx) + { self.confirm_on_update = Some(true) } else { self.delegate.confirm(true, cx); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 8051a4761af7bcfed6dae2f319e1bc92ae1de722..75961f8750056073aac533c0265ae5217804dd8a 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -884,3 +884,70 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { rename_request.next().await.unwrap(); cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal) } + +#[gpui::test] +async fn test_remap(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test moving the cursor + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g z", + workspace::SendKeystrokes("l l l l".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "z"]); + cx.assert_state("1234ˇ56789", Mode::Normal); + + // test switching modes + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g y", + workspace::SendKeystrokes("i f o o escape l".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "y"]); + cx.assert_state("fooˇ123456789", Mode::Normal); + + // test recursion + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g x", + workspace::SendKeystrokes("g z g y".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "x"]); + cx.assert_state("1234fooˇ56789", Mode::Normal); + + cx.executor().allow_parking(); + + // test command + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g w", + workspace::SendKeystrokes(": j enter".to_string()), + None, + )]) + }); + cx.set_state("ˇ1234\n56789", Mode::Normal); + cx.simulate_keystrokes(["g", "w"]); + cx.assert_state("1234ˇ 56789", Mode::Normal); + + // test leaving command + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g u", + workspace::SendKeystrokes("g w g z".to_string()), + None, + )]) + }); + cx.set_state("ˇ1234\n56789", Mode::Normal); + cx.simulate_keystrokes(["g", "u"]); + cx.assert_state("1234 567ˇ89", Mode::Normal); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 11dd49d373729a56d4314907621ed7e1e41cd52d..883a051d8329a78a8c8714028052bec8c91416a8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -29,10 +29,10 @@ use gpui::{ actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, - FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, - ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, - Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, - WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions, + FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke, + LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, + PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, + VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions, }; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem}; use itertools::Itertools; @@ -59,10 +59,11 @@ pub use status_bar::StatusItemView; use std::{ any::TypeId, borrow::Cow, + cell::RefCell, cmp, env, path::{Path, PathBuf}, - sync::Weak, - sync::{atomic::AtomicUsize, Arc}, + rc::Rc, + sync::{atomic::AtomicUsize, Arc, Weak}, time::Duration, }; use theme::{ActiveTheme, SystemAppearance, ThemeSettings}; @@ -157,6 +158,9 @@ pub struct CloseAllItemsAndPanes { pub save_intent: Option, } +#[derive(Clone, Deserialize, PartialEq)] +pub struct SendKeystrokes(pub String); + impl_actions!( workspace, [ @@ -168,6 +172,7 @@ impl_actions!( Save, SaveAll, SwapPaneInDirection, + SendKeystrokes, ] ); @@ -499,6 +504,7 @@ pub struct Workspace { leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, app_state: Arc, + dispatching_keystrokes: Rc>>, _subscriptions: Vec, _apply_leader_updates: Task>, _observe_current_user: Task>, @@ -754,6 +760,7 @@ impl Workspace { project: project.clone(), follower_states: Default::default(), last_leaders_by_pane: Default::default(), + dispatching_keystrokes: Default::default(), window_edited: false, active_call, database_id: workspace_id, @@ -1252,6 +1259,46 @@ impl Workspace { .detach_and_log_err(cx); } + fn send_keystrokes(&mut self, action: &SendKeystrokes, cx: &mut ViewContext) { + let mut keystrokes: Vec = action + .0 + .split(" ") + .flat_map(|k| Keystroke::parse(k).log_err()) + .collect(); + keystrokes.reverse(); + + self.dispatching_keystrokes + .borrow_mut() + .append(&mut keystrokes); + + let keystrokes = self.dispatching_keystrokes.clone(); + cx.window_context() + .spawn(|mut cx| async move { + // limit to 100 keystrokes to avoid infinite recursion. + for _ in 0..100 { + let Some(keystroke) = keystrokes.borrow_mut().pop() else { + return Ok(()); + }; + cx.update(|cx| { + let focused = cx.focused(); + cx.dispatch_keystroke(keystroke.clone()); + if cx.focused() != focused { + // dispatch_keystroke may cause the focus to change. + // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle + // And we need that to happen before the next keystroke to keep vim mode happy... + // (Note that the tests always do this implicitly, so you must manually test with something like: + // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} + // ) + cx.draw(); + } + })?; + } + keystrokes.borrow_mut().clear(); + Err(anyhow!("over 100 keystrokes passed to send_keystrokes")) + }) + .detach_and_log_err(cx); + } + fn save_all_internal( &mut self, mut save_intent: SaveIntent, @@ -3461,6 +3508,7 @@ impl Workspace { .on_action(cx.listener(Self::close_inactive_items_and_panes)) .on_action(cx.listener(Self::close_all_items_and_panes)) .on_action(cx.listener(Self::save_all)) + .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(Self::follow_next_collaborator)) .on_action(cx.listener(|workspace, _: &Unfollow, cx| {