mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-08 07:35:01 +03:00
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 <conrad.irwin@gmail.com>
This commit is contained in:
parent
8f5d7db875
commit
8a73bc4c7d
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2183,6 +2183,7 @@ dependencies = [
|
|||||||
"language",
|
"language",
|
||||||
"menu",
|
"menu",
|
||||||
"picker",
|
"picker",
|
||||||
|
"postage",
|
||||||
"project",
|
"project",
|
||||||
"release_channel",
|
"release_channel",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -28,6 +28,7 @@ ui.workspace = true
|
|||||||
util.workspace = true
|
util.workspace = true
|
||||||
workspace.workspace = true
|
workspace.workspace = true
|
||||||
zed_actions.workspace = true
|
zed_actions.workspace = true
|
||||||
|
postage.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ctor.workspace = true
|
ctor.workspace = true
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
cmp::{self, Reverse},
|
cmp::{self, Reverse},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use client::telemetry::Telemetry;
|
use client::telemetry::Telemetry;
|
||||||
@ -9,11 +10,12 @@ use copilot::CommandPaletteFilter;
|
|||||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global,
|
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 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 ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
|
||||||
use util::ResultExt;
|
use util::ResultExt;
|
||||||
use workspace::{ModalView, Workspace};
|
use workspace::{ModalView, Workspace};
|
||||||
@ -119,6 +121,10 @@ pub struct CommandPaletteDelegate {
|
|||||||
selected_ix: usize,
|
selected_ix: usize,
|
||||||
telemetry: Arc<Telemetry>,
|
telemetry: Arc<Telemetry>,
|
||||||
previous_focus_handle: FocusHandle,
|
previous_focus_handle: FocusHandle,
|
||||||
|
updating_matches: Option<(
|
||||||
|
Task<()>,
|
||||||
|
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
|
||||||
|
)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Command {
|
struct Command {
|
||||||
@ -138,7 +144,7 @@ impl Clone for Command {
|
|||||||
/// Hit count for each command in the palette.
|
/// 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
|
/// 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.
|
/// 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<String, usize>);
|
struct HitCounts(HashMap<String, usize>);
|
||||||
|
|
||||||
impl Global for HitCounts {}
|
impl Global for HitCounts {}
|
||||||
@ -158,6 +164,66 @@ impl CommandPaletteDelegate {
|
|||||||
selected_ix: 0,
|
selected_ix: 0,
|
||||||
telemetry,
|
telemetry,
|
||||||
previous_focus_handle,
|
previous_focus_handle,
|
||||||
|
updating_matches: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_updated(
|
||||||
|
&mut self,
|
||||||
|
query: String,
|
||||||
|
mut commands: Vec<Command>,
|
||||||
|
mut matches: Vec<StringMatch>,
|
||||||
|
cx: &mut ViewContext<Picker<Self>>,
|
||||||
|
) {
|
||||||
|
self.updating_matches.take();
|
||||||
|
|
||||||
|
let mut intercept_result =
|
||||||
|
if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
|
||||||
|
(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,
|
query: String,
|
||||||
cx: &mut ViewContext<Picker<Self>>,
|
cx: &mut ViewContext<Picker<Self>>,
|
||||||
) -> gpui::Task<()> {
|
) -> gpui::Task<()> {
|
||||||
let mut commands = self.all_commands.clone();
|
let (mut tx, mut rx) = postage::dispatch::channel(1);
|
||||||
|
let task = cx.background_executor().spawn({
|
||||||
cx.spawn(move |picker, mut cx| async move {
|
let mut commands = self.all_commands.clone();
|
||||||
cx.read_global::<HitCounts, _>(|hit_counts, _| {
|
let hit_counts = cx.global::<HitCounts>().clone();
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
let query = query.clone();
|
||||||
|
async move {
|
||||||
commands.sort_by_key(|action| {
|
commands.sort_by_key(|action| {
|
||||||
(
|
(
|
||||||
Reverse(hit_counts.0.get(&action.name).cloned()),
|
Reverse(hit_counts.0.get(&action.name).cloned()),
|
||||||
action.name.clone(),
|
action.name.clone(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let candidates = commands
|
let candidates = commands
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
|
||||||
.map(|(ix, command)| StringMatchCandidate {
|
|
||||||
id: ix,
|
|
||||||
string: command.name.to_string(),
|
|
||||||
char_bag: command.name.chars().collect(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut matches = if query.is_empty() {
|
|
||||||
candidates
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, candidate)| StringMatch {
|
.map(|(ix, command)| StringMatchCandidate {
|
||||||
candidate_id: index,
|
id: ix,
|
||||||
string: candidate.string,
|
string: command.name.to_string(),
|
||||||
positions: Vec::new(),
|
char_bag: command.name.chars().collect(),
|
||||||
score: 0.0,
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
} else {
|
|
||||||
fuzzy::match_strings(
|
let matches = if query.is_empty() {
|
||||||
&candidates,
|
candidates
|
||||||
&query,
|
.into_iter()
|
||||||
true,
|
.enumerate()
|
||||||
10000,
|
.map(|(index, candidate)| StringMatch {
|
||||||
&Default::default(),
|
candidate_id: index,
|
||||||
cx.background_executor().clone(),
|
string: candidate.string,
|
||||||
)
|
positions: Vec::new(),
|
||||||
.await
|
score: 0.0,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
let ret = fuzzy::match_strings(
|
||||||
|
&candidates,
|
||||||
|
&query,
|
||||||
|
true,
|
||||||
|
10000,
|
||||||
|
&Default::default(),
|
||||||
|
executor,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
ret
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.send((commands, matches)).await.log_err();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.updating_matches = Some((task, rx.clone()));
|
||||||
|
|
||||||
|
cx.spawn(move |picker, mut cx| async move {
|
||||||
|
let Some((commands, matches)) = rx.recv().await else {
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
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![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
picker
|
picker
|
||||||
.update(&mut cx, |picker, _| {
|
.update(&mut cx, |picker, cx| {
|
||||||
let delegate = &mut picker.delegate;
|
picker
|
||||||
delegate.commands = commands;
|
.delegate
|
||||||
delegate.matches = matches;
|
.matches_updated(query, commands, matches, cx)
|
||||||
if delegate.matches.is_empty() {
|
|
||||||
delegate.selected_ix = 0;
|
|
||||||
} else {
|
|
||||||
delegate.selected_ix =
|
|
||||||
cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.log_err();
|
.log_err();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finalize_update_matches(
|
||||||
|
&mut self,
|
||||||
|
query: String,
|
||||||
|
duration: Duration,
|
||||||
|
cx: &mut ViewContext<Picker<Self>>,
|
||||||
|
) -> 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<Picker<Self>>) {
|
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
|
||||||
self.command_palette
|
self.command_palette
|
||||||
.update(cx, |_, cx| cx.emit(DismissEvent))
|
.update(cx, |_, cx| cx.emit(DismissEvent))
|
||||||
|
@ -147,7 +147,7 @@ impl EditorTestContext {
|
|||||||
self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
|
self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
|
||||||
let keystroke = Keystroke::parse(keystroke_text).unwrap();
|
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
|
keystroke_under_test_handle
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ use std::any::{Any, TypeId};
|
|||||||
/// }
|
/// }
|
||||||
/// register_action!(Paste);
|
/// register_action!(Paste);
|
||||||
/// ```
|
/// ```
|
||||||
pub trait Action: 'static {
|
pub trait Action: 'static + Send {
|
||||||
/// Clone the action into a new box
|
/// Clone the action into a new box
|
||||||
fn boxed_clone(&self) -> Box<dyn Action>;
|
fn boxed_clone(&self) -> Box<dyn Action>;
|
||||||
|
|
||||||
|
@ -335,7 +335,7 @@ impl TestAppContext {
|
|||||||
.map(Keystroke::parse)
|
.map(Keystroke::parse)
|
||||||
.map(Result::unwrap)
|
.map(Result::unwrap)
|
||||||
{
|
{
|
||||||
self.dispatch_keystroke(window, keystroke.into(), false);
|
self.dispatch_keystroke(window, keystroke.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.background_executor.run_until_parked()
|
self.background_executor.run_until_parked()
|
||||||
@ -347,21 +347,16 @@ impl TestAppContext {
|
|||||||
/// This will also run the background executor until it's parked.
|
/// This will also run the background executor until it's parked.
|
||||||
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
|
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
|
||||||
for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) {
|
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()
|
self.background_executor.run_until_parked()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`)
|
/// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`)
|
||||||
pub fn dispatch_keystroke(
|
pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) {
|
||||||
&mut self,
|
self.update_window(window, |_, cx| cx.dispatch_keystroke(keystroke))
|
||||||
window: AnyWindowHandle,
|
.unwrap();
|
||||||
keystroke: Keystroke,
|
|
||||||
is_held: bool,
|
|
||||||
) {
|
|
||||||
self.test_window(window)
|
|
||||||
.simulate_keystroke(keystroke, is_held)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the `TestWindow` backing the given handle.
|
/// Returns the `TestWindow` backing the given handle.
|
||||||
|
@ -491,8 +491,8 @@ mod test {
|
|||||||
.update(cx, |test_view, cx| cx.focus(&test_view.focus_handle))
|
.update(cx, |test_view, cx| cx.focus(&test_view.focus_handle))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap(), false);
|
cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap());
|
||||||
cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap(), false);
|
cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap());
|
||||||
|
|
||||||
window
|
window
|
||||||
.update(cx, |test_view, _| {
|
.update(cx, |test_view, _| {
|
||||||
|
@ -412,7 +412,7 @@ impl PlatformInputHandler {
|
|||||||
.flatten()
|
.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);
|
self.handler.replace_text_in_range(None, input, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke,
|
px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, Pixels, PlatformAtlas,
|
||||||
Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
|
PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, Size,
|
||||||
Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions,
|
TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@ -112,41 +112,6 @@ impl TestWindow {
|
|||||||
self.0.lock().input_callback = Some(callback);
|
self.0.lock().input_callback = Some(callback);
|
||||||
result
|
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 {
|
impl PlatformWindow for TestWindow {
|
||||||
|
@ -950,7 +950,7 @@ impl<'a> WindowContext<'a> {
|
|||||||
|
|
||||||
/// Produces a new frame and assigns it to `rendered_frame`. To actually show
|
/// Produces a new frame and assigns it to `rendered_frame`. To actually show
|
||||||
/// the contents of the new [Scene], use [present].
|
/// 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.dirty.set(false);
|
||||||
self.window.drawing = true;
|
self.window.drawing = true;
|
||||||
|
|
||||||
@ -1099,6 +1099,38 @@ impl<'a> WindowContext<'a> {
|
|||||||
self.window.needs_present.set(false);
|
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.
|
/// Dispatch a mouse or keyboard event on the window.
|
||||||
pub fn dispatch_event(&mut self, event: PlatformInput) -> bool {
|
pub fn dispatch_event(&mut self, event: PlatformInput) -> bool {
|
||||||
self.window.last_input_timestamp.set(Instant::now());
|
self.window.last_input_timestamp.set(Instant::now());
|
||||||
@ -1423,7 +1455,7 @@ impl<'a> WindowContext<'a> {
|
|||||||
|
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
if let Some(mut input_handler) = self.window.platform_window.take_input_handler() {
|
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)
|
self.window.platform_window.set_input_handler(input_handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ use gpui::{
|
|||||||
FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
|
FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
|
||||||
UniformListScrollHandle, View, ViewContext, WindowContext,
|
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 ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
|
||||||
use workspace::ModalView;
|
use workspace::ModalView;
|
||||||
|
|
||||||
@ -40,6 +40,19 @@ pub trait PickerDelegate: Sized + 'static {
|
|||||||
fn placeholder_text(&self) -> Arc<str>;
|
fn placeholder_text(&self) -> Arc<str>;
|
||||||
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
|
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> 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<Picker<Self>>,
|
||||||
|
) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
|
fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
|
||||||
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
|
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
|
||||||
|
|
||||||
@ -98,6 +111,9 @@ impl<D: PickerDelegate> Picker<D> {
|
|||||||
is_modal: true,
|
is_modal: true,
|
||||||
};
|
};
|
||||||
this.update_matches("".to_string(), cx);
|
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
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,15 +213,24 @@ impl<D: PickerDelegate> Picker<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
|
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
|
||||||
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)
|
self.confirm_on_update = Some(false)
|
||||||
} else {
|
} else {
|
||||||
|
self.pending_update_matches.take();
|
||||||
self.delegate.confirm(false, cx);
|
self.delegate.confirm(false, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
|
fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
|
||||||
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)
|
self.confirm_on_update = Some(true)
|
||||||
} else {
|
} else {
|
||||||
self.delegate.confirm(true, cx);
|
self.delegate.confirm(true, cx);
|
||||||
|
@ -884,3 +884,70 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
|
|||||||
rename_request.next().await.unwrap();
|
rename_request.next().await.unwrap();
|
||||||
cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal)
|
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);
|
||||||
|
}
|
||||||
|
@ -29,10 +29,10 @@ use gpui::{
|
|||||||
actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView,
|
actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView,
|
||||||
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div,
|
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div,
|
||||||
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
|
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
|
||||||
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId,
|
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke,
|
||||||
ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
|
LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point,
|
||||||
Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext,
|
PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext,
|
||||||
WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
|
VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
|
||||||
};
|
};
|
||||||
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
|
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@ -59,10 +59,11 @@ pub use status_bar::StatusItemView;
|
|||||||
use std::{
|
use std::{
|
||||||
any::TypeId,
|
any::TypeId,
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
|
cell::RefCell,
|
||||||
cmp, env,
|
cmp, env,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Weak,
|
rc::Rc,
|
||||||
sync::{atomic::AtomicUsize, Arc},
|
sync::{atomic::AtomicUsize, Arc, Weak},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
|
use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
|
||||||
@ -157,6 +158,9 @@ pub struct CloseAllItemsAndPanes {
|
|||||||
pub save_intent: Option<SaveIntent>,
|
pub save_intent: Option<SaveIntent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, PartialEq)]
|
||||||
|
pub struct SendKeystrokes(pub String);
|
||||||
|
|
||||||
impl_actions!(
|
impl_actions!(
|
||||||
workspace,
|
workspace,
|
||||||
[
|
[
|
||||||
@ -168,6 +172,7 @@ impl_actions!(
|
|||||||
Save,
|
Save,
|
||||||
SaveAll,
|
SaveAll,
|
||||||
SwapPaneInDirection,
|
SwapPaneInDirection,
|
||||||
|
SendKeystrokes,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -499,6 +504,7 @@ pub struct Workspace {
|
|||||||
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
|
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
|
||||||
database_id: WorkspaceId,
|
database_id: WorkspaceId,
|
||||||
app_state: Arc<AppState>,
|
app_state: Arc<AppState>,
|
||||||
|
dispatching_keystrokes: Rc<RefCell<Vec<Keystroke>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
_apply_leader_updates: Task<Result<()>>,
|
_apply_leader_updates: Task<Result<()>>,
|
||||||
_observe_current_user: Task<Result<()>>,
|
_observe_current_user: Task<Result<()>>,
|
||||||
@ -754,6 +760,7 @@ impl Workspace {
|
|||||||
project: project.clone(),
|
project: project.clone(),
|
||||||
follower_states: Default::default(),
|
follower_states: Default::default(),
|
||||||
last_leaders_by_pane: Default::default(),
|
last_leaders_by_pane: Default::default(),
|
||||||
|
dispatching_keystrokes: Default::default(),
|
||||||
window_edited: false,
|
window_edited: false,
|
||||||
active_call,
|
active_call,
|
||||||
database_id: workspace_id,
|
database_id: workspace_id,
|
||||||
@ -1252,6 +1259,46 @@ impl Workspace {
|
|||||||
.detach_and_log_err(cx);
|
.detach_and_log_err(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn send_keystrokes(&mut self, action: &SendKeystrokes, cx: &mut ViewContext<Self>) {
|
||||||
|
let mut keystrokes: Vec<Keystroke> = 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 <enter> 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(
|
fn save_all_internal(
|
||||||
&mut self,
|
&mut self,
|
||||||
mut save_intent: SaveIntent,
|
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_inactive_items_and_panes))
|
||||||
.on_action(cx.listener(Self::close_all_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::save_all))
|
||||||
|
.on_action(cx.listener(Self::send_keystrokes))
|
||||||
.on_action(cx.listener(Self::add_folder_to_project))
|
.on_action(cx.listener(Self::add_folder_to_project))
|
||||||
.on_action(cx.listener(Self::follow_next_collaborator))
|
.on_action(cx.listener(Self::follow_next_collaborator))
|
||||||
.on_action(cx.listener(|workspace, _: &Unfollow, cx| {
|
.on_action(cx.listener(|workspace, _: &Unfollow, cx| {
|
||||||
|
Loading…
Reference in New Issue
Block a user