mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-19 11:04:06 +00:00
gpui: Fix keystroke interception for standalone modifiers (#64537)
I ran into this while working on keybindings for the pending-keystrokes
indicator. Keystroke interceptors never received standalone modifiers.
GPUI recognizes a standalone modifier on release through a
`ModifiersChangedEvent`. But interception only handled `KeyDownEvent`s,
so the stroke went straight to keymap dispatch. This change passes the
recognized keystroke to interceptors instead of checking the raw event
type.
1. Recording standalone modifiers
With this binding in `keymap.json`, start recording a binding, then
press and release Shift:
```json
[
{
"bindings": {
"shift": "command_palette::Toggle"
}
}
]
```
- Before, the command palette opens and Shift is not recorded.
- After, the recorder captures `shift` without opening the command
palette.
2. Searching for standalone modifiers
With these bindings, enable keystroke search, turn exact matching off,
then press and release Control:
```json
[
{
"context": "Editor",
"bindings": {
"ctrl": "command_palette::Toggle",
"ctrl-k": "command_palette::Toggle"
}
}
]
```
- Before, holding Control finds Ctrl-K but not Control alone. Releasing
Control clears the modifier query.
- After, releasing Control records it as a standalone key, and non-exact
search finds both Ctrl-K and Control alone.
Release Notes:
- Fixed recording and searching for standalone modifier-key bindings in
the keymap editor.
- [GPUI] Fixed keystroke interceptors to receive standalone modifier
keystrokes.
This commit is contained in:
parent
5299a14401
commit
ba42ab9d98
7 changed files with 677 additions and 44 deletions
|
|
@ -2289,9 +2289,10 @@ impl App {
|
|||
})
|
||||
}
|
||||
|
||||
/// Register a callback to be invoked when a keystroke is received by the application
|
||||
/// in any window. Note that this fires after all other action and event mechanisms have resolved
|
||||
/// and that this API will not be invoked if the event's propagation is stopped.
|
||||
/// Register a callback to be invoked after a keystroke is resolved in any window,
|
||||
/// including the action that handled it, if any. Keystrokes consumed by an
|
||||
/// interceptor or raw keyboard event handler are not observed.
|
||||
/// Standalone modifiers are observed on release.
|
||||
pub fn observe_keystrokes(
|
||||
&mut self,
|
||||
mut f: impl FnMut(&KeystrokeEvent, &mut Window, &mut App) + 'static,
|
||||
|
|
|
|||
|
|
@ -489,9 +489,7 @@ impl<'a, T: 'static> Context<'a, T> {
|
|||
subscription
|
||||
}
|
||||
|
||||
/// Register a callback to be invoked when a keystroke is received by the application
|
||||
/// in any window. Note that this fires after all other action and event mechanisms have resolved
|
||||
/// and that this API will not be invoked if the event's propagation is stopped.
|
||||
/// Registers a callback for [`App::observe_keystrokes`] that updates this entity.
|
||||
pub fn observe_keystrokes(
|
||||
&mut self,
|
||||
mut f: impl FnMut(&mut T, &KeystrokeEvent, &mut Window, &mut Context<T>) + 'static,
|
||||
|
|
|
|||
|
|
@ -633,10 +633,10 @@ mod tests {
|
|||
};
|
||||
|
||||
use crate::{
|
||||
ActionRegistry, App, Bounds, Context, DispatchPhase, DispatchTree, FocusHandle,
|
||||
InputHandler, IntoElement, KeyBinding, KeyContext, Keymap, Pixels, PlatformWindow, Point,
|
||||
Render, Subscription, TestAppContext, UTF16Selection, Unbind, VisualContext,
|
||||
VisualTestContext, Window,
|
||||
Action, ActionRegistry, App, Bounds, Context, DispatchPhase, DispatchTree, FocusHandle,
|
||||
InputHandler, IntoElement, KeyBinding, KeyContext, Keymap, Modifiers, Pixels,
|
||||
PlatformWindow, Point, Render, Subscription, TestAppContext, UTF16Selection, Unbind,
|
||||
VisualContext, VisualTestContext, Window,
|
||||
};
|
||||
|
||||
actions!(dispatch_test, [TestAction, SecondaryTestAction]);
|
||||
|
|
@ -865,6 +865,41 @@ mod tests {
|
|||
(cx, action_count, secondary_action_count)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct ObservedKeystroke {
|
||||
keystroke: Keystroke,
|
||||
action_name: Option<&'static str>,
|
||||
context_stack: Vec<KeyContext>,
|
||||
}
|
||||
|
||||
fn capture_observed_keystrokes(
|
||||
cx: &mut VisualTestContext,
|
||||
) -> (Rc<RefCell<Vec<ObservedKeystroke>>>, Subscription) {
|
||||
let observed_keystrokes = Rc::new(RefCell::new(Vec::new()));
|
||||
let subscription = cx.update(|_, cx| {
|
||||
cx.observe_keystrokes({
|
||||
let observed_keystrokes = observed_keystrokes.clone();
|
||||
move |event, _, _| {
|
||||
observed_keystrokes.borrow_mut().push(ObservedKeystroke {
|
||||
keystroke: event.keystroke.clone(),
|
||||
action_name: event.action.as_ref().map(|action| action.name()),
|
||||
context_stack: event.context_stack.clone(),
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
(observed_keystrokes, subscription)
|
||||
}
|
||||
|
||||
fn simulate_modifier_tap(cx: &mut VisualTestContext, modifiers: Modifiers) {
|
||||
cx.simulate_modifiers_change(modifiers);
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
}
|
||||
|
||||
fn terminal_context() -> KeyContext {
|
||||
KeyContext::parse("Terminal").expect("valid key context")
|
||||
}
|
||||
|
||||
fn setup_pending_input_timeout_test(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
|
||||
|
|
@ -1304,6 +1339,307 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_standalone_modifier_dispatch(cx: &mut TestAppContext) {
|
||||
let (cx, action_count, _) =
|
||||
setup_pending_input_test(cx, [KeyBinding::new("shift", TestAction, Some("Terminal"))]);
|
||||
let intercepted_keystrokes = Rc::new(RefCell::new(Vec::new()));
|
||||
let should_consume = Rc::new(Cell::new(true));
|
||||
let terminal_context = terminal_context();
|
||||
let (observed_keystrokes, _observer) = capture_observed_keystrokes(cx);
|
||||
let _interceptor = cx.update(|_, cx| {
|
||||
cx.intercept_keystrokes({
|
||||
let intercepted_keystrokes = intercepted_keystrokes.clone();
|
||||
let should_consume = should_consume.clone();
|
||||
let terminal_context = terminal_context.clone();
|
||||
move |event, _, cx| {
|
||||
assert!(event.action.is_none());
|
||||
assert_eq!(event.context_stack, vec![terminal_context.clone()]);
|
||||
intercepted_keystrokes
|
||||
.borrow_mut()
|
||||
.push(event.keystroke.clone());
|
||||
if should_consume.get() {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let shift = Keystroke::parse("shift").expect("valid keystroke");
|
||||
|
||||
// Pressing Shift is not a keystroke. Consuming its recognized release suppresses
|
||||
// the binding.
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
assert!(intercepted_keystrokes.borrow().is_empty());
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
assert_eq!(
|
||||
intercepted_keystrokes.borrow().as_slice(),
|
||||
std::slice::from_ref(&shift)
|
||||
);
|
||||
assert_eq!(action_count.get(), 0);
|
||||
assert!(observed_keystrokes.borrow().is_empty());
|
||||
|
||||
// Without consumption, the same recognized release reaches keymap dispatch.
|
||||
should_consume.set(false);
|
||||
intercepted_keystrokes.borrow_mut().clear();
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
assert!(intercepted_keystrokes.borrow().is_empty());
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
assert_eq!(
|
||||
intercepted_keystrokes.borrow().as_slice(),
|
||||
std::slice::from_ref(&shift)
|
||||
);
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: shift,
|
||||
action_name: Some(TestAction.name()),
|
||||
context_stack: vec![terminal_context],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_unbound_standalone_modifier_observation(cx: &mut TestAppContext) {
|
||||
let (cx, _, _) = setup_pending_input_test(cx, []);
|
||||
let terminal_context = terminal_context();
|
||||
let (observed_keystrokes, _observer) = capture_observed_keystrokes(cx);
|
||||
|
||||
for (modifiers, key) in [
|
||||
(Modifiers::shift(), "shift"),
|
||||
(Modifiers::control(), "ctrl"),
|
||||
(Modifiers::alt(), "alt"),
|
||||
(Modifiers::command(), "cmd"),
|
||||
(Modifiers::function(), "fn"),
|
||||
] {
|
||||
observed_keystrokes.borrow_mut().clear();
|
||||
cx.simulate_modifiers_change(modifiers);
|
||||
assert!(
|
||||
observed_keystrokes.borrow().is_empty(),
|
||||
"modifier press must not notify observers"
|
||||
);
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: Keystroke::parse(key).expect("valid modifier"),
|
||||
action_name: None,
|
||||
context_stack: vec![terminal_context.clone()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_raw_modifier_handler_can_suppress_observation(cx: &mut TestAppContext) {
|
||||
struct ModifierListenerTestView {
|
||||
focus_handle: FocusHandle,
|
||||
consume: Rc<Cell<bool>>,
|
||||
releases: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Render for ModifierListenerTestView {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
use crate::{InteractiveElement as _, Styled as _};
|
||||
let consume = self.consume.clone();
|
||||
let releases = self.releases.clone();
|
||||
crate::div()
|
||||
.key_context("Terminal")
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.on_action(|_: &TestAction, _, cx| cx.propagate())
|
||||
.on_modifiers_changed(move |event, _, cx| {
|
||||
if !event.modifiers.modified() {
|
||||
releases.set(releases.get() + 1);
|
||||
if consume.get() {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.bind_keys([KeyBinding::new("shift", TestAction, Some("Terminal"))]);
|
||||
});
|
||||
let consume = Rc::new(Cell::new(true));
|
||||
let releases = Rc::new(Cell::new(0));
|
||||
let (view, cx) = cx.add_window_view(|_, cx| ModifierListenerTestView {
|
||||
focus_handle: cx.focus_handle(),
|
||||
consume: consume.clone(),
|
||||
releases: releases.clone(),
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
let focus_handle = view.read(cx).focus_handle.clone();
|
||||
window.focus(&focus_handle, cx);
|
||||
window.activate_window();
|
||||
});
|
||||
let (observed_keystrokes, _observer) = capture_observed_keystrokes(cx);
|
||||
|
||||
simulate_modifier_tap(cx, Modifiers::shift());
|
||||
assert_eq!(releases.get(), 1);
|
||||
assert!(observed_keystrokes.borrow().is_empty());
|
||||
|
||||
consume.set(false);
|
||||
simulate_modifier_tap(cx, Modifiers::shift());
|
||||
assert_eq!(releases.get(), 2);
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: Keystroke::parse("shift").expect("valid modifier"),
|
||||
action_name: None,
|
||||
context_stack: vec![terminal_context()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_pending_modifier_observed_when_binding_resolves(cx: &mut TestAppContext) {
|
||||
let (cx, action_count, secondary_action_count) = setup_pending_input_test(
|
||||
cx,
|
||||
[
|
||||
KeyBinding::new("shift", TestAction, Some("Terminal")),
|
||||
KeyBinding::new("shift f1", SecondaryTestAction, Some("Terminal")),
|
||||
],
|
||||
);
|
||||
let (observed_keystrokes, _observer) = capture_observed_keystrokes(cx);
|
||||
|
||||
simulate_modifier_tap(cx, Modifiers::shift());
|
||||
assert!(observed_keystrokes.borrow().is_empty());
|
||||
assert_eq!(action_count.get(), 0);
|
||||
|
||||
cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: Keystroke::parse("shift").expect("valid modifier"),
|
||||
action_name: Some(TestAction.name()),
|
||||
context_stack: Vec::new(),
|
||||
}]
|
||||
);
|
||||
|
||||
observed_keystrokes.borrow_mut().clear();
|
||||
simulate_modifier_tap(cx, Modifiers::shift());
|
||||
assert!(observed_keystrokes.borrow().is_empty());
|
||||
cx.simulate_keystrokes("f1");
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert_eq!(secondary_action_count.get(), 1);
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: Keystroke::parse("f1").expect("valid key"),
|
||||
action_name: Some(SecondaryTestAction.name()),
|
||||
context_stack: vec![terminal_context()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_combined_shortcut_has_no_standalone_modifier(cx: &mut TestAppContext) {
|
||||
let (cx, action_count, _) = setup_pending_input_test(
|
||||
cx,
|
||||
[
|
||||
KeyBinding::new("shift", TestAction, Some("Terminal")),
|
||||
KeyBinding::new("shift-f1", TestAction, Some("Terminal")),
|
||||
],
|
||||
);
|
||||
let intercepted_keystrokes = Rc::new(RefCell::new(Vec::new()));
|
||||
let (observed_keystrokes, _observer) = capture_observed_keystrokes(cx);
|
||||
let _interceptor = cx.update(|_, cx| {
|
||||
cx.intercept_keystrokes({
|
||||
let intercepted_keystrokes = intercepted_keystrokes.clone();
|
||||
move |event, _, _| {
|
||||
intercepted_keystrokes
|
||||
.borrow_mut()
|
||||
.push(event.keystroke.clone());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Shift-F1 is the only keystroke. Releasing Shift must not emit a second one.
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
assert!(intercepted_keystrokes.borrow().is_empty());
|
||||
cx.simulate_keystrokes("shift-f1");
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
|
||||
let shift_f1 = Keystroke::parse("shift-f1").expect("valid keystroke");
|
||||
assert_eq!(
|
||||
intercepted_keystrokes.borrow().as_slice(),
|
||||
std::slice::from_ref(&shift_f1)
|
||||
);
|
||||
assert_eq!(
|
||||
observed_keystrokes.borrow().as_slice(),
|
||||
&[ObservedKeystroke {
|
||||
keystroke: shift_f1,
|
||||
action_name: Some(TestAction.name()),
|
||||
context_stack: vec![terminal_context()],
|
||||
}]
|
||||
);
|
||||
assert_eq!(action_count.get(), 1);
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_consumed_modifier_preserves_pending_input(cx: &mut TestAppContext) {
|
||||
let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx));
|
||||
test.update_in(cx, |test, window, cx| {
|
||||
window.focus(&test.focus_handle, cx);
|
||||
window.activate_window();
|
||||
});
|
||||
let _subscription = cx.update(|_, cx| {
|
||||
cx.intercept_keystrokes(|event, _, cx| {
|
||||
if event.keystroke.key == "shift" {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Exercise both paths that processing Shift would take: completing `j shift`, and
|
||||
// replaying `j` when `j k` no longer matches.
|
||||
for binding in ["j shift", "j k"] {
|
||||
cx.update(|_, cx| {
|
||||
cx.clear_key_bindings();
|
||||
cx.bind_keys([KeyBinding::new(binding, TestAction, Some("Terminal"))]);
|
||||
});
|
||||
test.update(cx, |test, _| test.text.borrow_mut().clear());
|
||||
cx.simulate_keystrokes("j");
|
||||
cx.executor()
|
||||
.advance_clock(crate::PENDING_INPUT_TIMEOUT / 2);
|
||||
cx.run_until_parked();
|
||||
let pending_keystrokes = cx.update(|window, _| {
|
||||
window
|
||||
.pending_input()
|
||||
.expect("pending input")
|
||||
.keystrokes()
|
||||
.to_vec()
|
||||
});
|
||||
|
||||
// Consuming Shift must not complete or replay the pending `j`.
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
cx.update(|window, _| {
|
||||
let pending_input = window.pending_input().expect("pending input");
|
||||
assert_eq!(pending_input.keystrokes(), pending_keystrokes);
|
||||
assert!(pending_input.timeout().is_some());
|
||||
});
|
||||
test.update(cx, |test, _| {
|
||||
assert_eq!(test.action_count.get(), 0);
|
||||
assert_eq!(test.text.borrow().as_str(), "");
|
||||
});
|
||||
|
||||
// The unchanged timeout should replay `j` after its remaining half.
|
||||
cx.executor()
|
||||
.advance_clock(crate::PENDING_INPUT_TIMEOUT / 2);
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| assert!(!window.has_pending_keystrokes()));
|
||||
test.update(cx, |test, _| {
|
||||
assert_eq!(test.action_count.get(), 0);
|
||||
assert_eq!(test.text.borrow().as_str(), "j");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[crate::test]
|
||||
fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
|
|
|
|||
|
|
@ -2455,20 +2455,16 @@ impl Window {
|
|||
|
||||
pub(crate) fn dispatch_keystroke_observers(
|
||||
&mut self,
|
||||
event: &dyn Any,
|
||||
action: Option<Box<dyn Action>>,
|
||||
keystroke: &Keystroke,
|
||||
action: Option<&dyn Action>,
|
||||
context_stack: Vec<KeyContext>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.keystroke_observers.clone().retain(&(), move |callback| {
|
||||
(callback)(
|
||||
&KeystrokeEvent {
|
||||
keystroke: key_down_event.keystroke.clone(),
|
||||
action: action.as_ref().map(|action| action.boxed_clone()),
|
||||
keystroke: keystroke.clone(),
|
||||
action: action.map(|action| action.boxed_clone()),
|
||||
context_stack: context_stack.clone(),
|
||||
},
|
||||
self,
|
||||
|
|
@ -2479,20 +2475,16 @@ impl Window {
|
|||
|
||||
pub(crate) fn dispatch_keystroke_interceptors(
|
||||
&mut self,
|
||||
event: &dyn Any,
|
||||
keystroke: &Keystroke,
|
||||
context_stack: Vec<KeyContext>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.keystroke_interceptors
|
||||
.clone()
|
||||
.retain(&(), move |callback| {
|
||||
(callback)(
|
||||
&KeystrokeEvent {
|
||||
keystroke: key_down_event.keystroke.clone(),
|
||||
keystroke: keystroke.clone(),
|
||||
action: None,
|
||||
context_stack: context_stack.clone(),
|
||||
},
|
||||
|
|
@ -5861,14 +5853,20 @@ impl Window {
|
|||
}
|
||||
|
||||
let Some(keystroke) = keystroke else {
|
||||
self.finish_dispatch_key_event(event, dispatch_path, self.context_stack(), cx);
|
||||
self.finish_dispatch_key_event(event, None, dispatch_path, self.context_stack(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.propagate_event = true;
|
||||
self.dispatch_keystroke_interceptors(event, self.context_stack(), cx);
|
||||
self.dispatch_keystroke_interceptors(&keystroke, self.context_stack(), cx);
|
||||
if !cx.propagate_event {
|
||||
self.finish_dispatch_key_event(event, dispatch_path, self.context_stack(), cx);
|
||||
self.finish_dispatch_key_event(
|
||||
event,
|
||||
Some(&keystroke),
|
||||
dispatch_path,
|
||||
self.context_stack(),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5879,7 +5877,7 @@ impl Window {
|
|||
|
||||
let match_result = self.rendered_frame.dispatch_tree.dispatch_key(
|
||||
currently_pending.keystrokes,
|
||||
keystroke,
|
||||
keystroke.clone(),
|
||||
&dispatch_path,
|
||||
);
|
||||
|
||||
|
|
@ -5947,8 +5945,8 @@ impl Window {
|
|||
self.dispatch_action_on_node(node_id, binding.action.as_ref(), cx);
|
||||
if !cx.propagate_event {
|
||||
self.dispatch_keystroke_observers(
|
||||
event,
|
||||
Some(binding.action),
|
||||
&keystroke,
|
||||
Some(binding.action.as_ref()),
|
||||
match_result.context_stack,
|
||||
cx,
|
||||
);
|
||||
|
|
@ -5958,7 +5956,13 @@ impl Window {
|
|||
}
|
||||
}
|
||||
|
||||
self.finish_dispatch_key_event(event, dispatch_path, match_result.context_stack, cx);
|
||||
self.finish_dispatch_key_event(
|
||||
event,
|
||||
Some(&keystroke),
|
||||
dispatch_path,
|
||||
match_result.context_stack,
|
||||
cx,
|
||||
);
|
||||
self.pending_input_changed(cx);
|
||||
}
|
||||
|
||||
|
|
@ -6003,6 +6007,7 @@ impl Window {
|
|||
fn finish_dispatch_key_event(
|
||||
&mut self,
|
||||
event: &dyn Any,
|
||||
recognized_keystroke: Option<&Keystroke>,
|
||||
dispatch_path: SmallVec<[DispatchNodeId; 32]>,
|
||||
context_stack: Vec<KeyContext>,
|
||||
cx: &mut App,
|
||||
|
|
@ -6017,7 +6022,9 @@ impl Window {
|
|||
return;
|
||||
}
|
||||
|
||||
self.dispatch_keystroke_observers(event, None, context_stack, cx);
|
||||
if let Some(keystroke) = recognized_keystroke {
|
||||
self.dispatch_keystroke_observers(keystroke, None, context_stack, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pending_input_changed(&mut self, cx: &mut App) {
|
||||
|
|
@ -6219,8 +6226,8 @@ impl Window {
|
|||
self.dispatch_action_on_node(node_id, binding.action.as_ref(), cx);
|
||||
if !cx.propagate_event {
|
||||
self.dispatch_keystroke_observers(
|
||||
&event,
|
||||
Some(binding.action),
|
||||
&replay.keystroke,
|
||||
Some(binding.action.as_ref()),
|
||||
Vec::default(),
|
||||
cx,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -503,6 +503,33 @@ fn keystrokes_match_exactly(
|
|||
})
|
||||
}
|
||||
|
||||
fn keystroke_matches_partially(
|
||||
query: &KeybindingKeystroke,
|
||||
candidate: &KeybindingKeystroke,
|
||||
) -> bool {
|
||||
let query = query.inner();
|
||||
let candidate = candidate.inner();
|
||||
let key_matches = query.key.is_empty()
|
||||
|| query.key == candidate.key
|
||||
// Releasing a modifier records it as a key. Partial search must still find shortcuts
|
||||
// using that modifier, just as it does while the modifier is held.
|
||||
|| match query.key.as_str() {
|
||||
"control" => candidate.modifiers.control,
|
||||
"alt" => candidate.modifiers.alt,
|
||||
"shift" => candidate.modifiers.shift,
|
||||
"platform" => candidate.modifiers.platform,
|
||||
"function" => candidate.modifiers.function,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
query.modifiers.is_subset_of(&candidate.modifiers)
|
||||
&& key_matches
|
||||
&& query
|
||||
.key_char
|
||||
.as_ref()
|
||||
.is_none_or(|key_char| key_char == &candidate.key)
|
||||
}
|
||||
|
||||
fn disabled_binding_matches_context(
|
||||
disabled_binding: &gpui::KeyBinding,
|
||||
binding: &gpui::KeyBinding,
|
||||
|
|
@ -759,16 +786,7 @@ impl KeymapEditor {
|
|||
{
|
||||
let query = &keystroke_query[query_cursor];
|
||||
let keystroke = &keystrokes[keystroke_cursor];
|
||||
let matches = query
|
||||
.inner()
|
||||
.modifiers
|
||||
.is_subset_of(&keystroke.inner().modifiers)
|
||||
&& ((query.inner().key.is_empty()
|
||||
|| query.inner().key == keystroke.inner().key)
|
||||
&& query.inner().key_char.as_ref().is_none_or(
|
||||
|q_kc| q_kc == &keystroke.inner().key,
|
||||
));
|
||||
if matches {
|
||||
if keystroke_matches_partially(query, keystroke) {
|
||||
found_count += 1;
|
||||
query_cursor += 1;
|
||||
}
|
||||
|
|
@ -4102,6 +4120,124 @@ mod tests {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_keystroke_matching() {
|
||||
// A released modifier becomes a key, but must still act as a modifier in partial searches.
|
||||
for (query, binding, expected) in [
|
||||
("ctrl", "ctrl-f1", true),
|
||||
("alt", "alt-f1", true),
|
||||
("shift", "shift-f1", true),
|
||||
("cmd", "cmd-f1", true),
|
||||
("fn", "fn-f1", true),
|
||||
("ctrl", "ctrl", true),
|
||||
("ctrl", "alt-f1", false),
|
||||
("alt", "shift-f1", false),
|
||||
("shift", "cmd-f1", false),
|
||||
("cmd", "fn-f1", false),
|
||||
("fn", "ctrl-f1", false),
|
||||
("ctrl-shift", "ctrl-shift-f1", true),
|
||||
("ctrl-shift", "shift-f1", false),
|
||||
("f1", "ctrl-f1", true),
|
||||
("ctrl-f1", "ctrl-shift-f1", true),
|
||||
("ctrl-f1", "f1", false),
|
||||
("f1", "f2", false),
|
||||
("a->a", "ctrl-a", true),
|
||||
("a->å", "ctrl-a", false),
|
||||
] {
|
||||
let query = KeybindingKeystroke::from_keystroke(
|
||||
gpui::Keystroke::parse(query).expect("valid query"),
|
||||
);
|
||||
let binding = KeybindingKeystroke::from_keystroke(
|
||||
gpui::Keystroke::parse(binding).expect("valid binding"),
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_matches_partially(&query, &binding),
|
||||
expected,
|
||||
"query: {query:?}, binding: {binding:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_modifier_search_keeps_matching_shortcuts_after_release(cx: &mut TestAppContext) {
|
||||
let keymap_content = r#"[{"bindings": {
|
||||
"ctrl-k": "zed::OpenKeymap",
|
||||
"ctrl": "zed::OpenKeymap"
|
||||
}}]"#;
|
||||
let (_fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await;
|
||||
let workspace = keymap_editor.read_with(&cx, |editor, _| editor.workspace.clone());
|
||||
// Mount and activate the editor so keystroke search installs its interceptor.
|
||||
workspace
|
||||
.update_in(&mut cx, |workspace, window, cx| {
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(keymap_editor.clone()),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
window.activate_window();
|
||||
})
|
||||
.expect("workspace exists");
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
keymap_editor.update_in(&mut cx, |editor, window, cx| {
|
||||
editor.toggle_keystroke_search(&ToggleKeystrokeSearch, window, cx);
|
||||
editor.toggle_exact_keystroke_matching(&ToggleExactKeystrokeMatching, window, cx);
|
||||
});
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// While held, Control is a modifier query that finds Ctrl-K.
|
||||
cx.simulate_modifiers_change(gpui::Modifiers::control());
|
||||
cx.run_until_parked();
|
||||
keymap_editor.read_with(&cx, |editor, cx| {
|
||||
assert_eq!(visible_rows_for_action(editor, "zed::OpenKeymap").len(), 1);
|
||||
let query = editor.current_keystroke_query(cx);
|
||||
assert_eq!(query.len(), 1);
|
||||
assert!(query[0].key().is_empty());
|
||||
assert!(query[0].modifiers().control);
|
||||
});
|
||||
|
||||
// On release, Control becomes a key but partial search must match both forms.
|
||||
cx.simulate_modifiers_change(gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
keymap_editor.read_with(&cx, |editor, cx| {
|
||||
let query = editor.current_keystroke_query(cx);
|
||||
assert_eq!(query.len(), 1);
|
||||
assert_eq!(query[0].key(), "control");
|
||||
assert!(!query[0].modifiers().modified());
|
||||
assert_eq!(
|
||||
visible_rows_for_action(editor, "zed::OpenKeymap").len(),
|
||||
2,
|
||||
"Control must find both the standalone binding and Ctrl-K; query: {:?}",
|
||||
query
|
||||
);
|
||||
});
|
||||
|
||||
// Exact search narrows the same query to the standalone Control binding.
|
||||
keymap_editor.update_in(&mut cx, |editor, window, cx| {
|
||||
editor.toggle_exact_keystroke_matching(&ToggleExactKeystrokeMatching, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
keymap_editor.read_with(&cx, |editor, _| {
|
||||
let rows = visible_rows_for_action(editor, "zed::OpenKeymap");
|
||||
assert_eq!(rows.len(), 1);
|
||||
let binding = &editor.keybindings[editor.matches[rows[0]].candidate_id];
|
||||
let keystrokes = binding.keystrokes().expect("binding has keystrokes");
|
||||
assert_eq!(keystrokes.len(), 1);
|
||||
assert_eq!(keystrokes[0].key(), "control");
|
||||
});
|
||||
|
||||
// Returning to partial search restores both matches.
|
||||
keymap_editor.update_in(&mut cx, |editor, window, cx| {
|
||||
editor.toggle_exact_keystroke_matching(&ToggleExactKeystrokeMatching, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
keymap_editor.read_with(&cx, |editor, _| {
|
||||
assert_eq!(visible_rows_for_action(editor, "zed::OpenKeymap").len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_one_of_two_identical_user_bindings(cx: &mut TestAppContext) {
|
||||
let keymap_content = r#"[
|
||||
|
|
|
|||
|
|
@ -844,6 +844,19 @@ mod tests {
|
|||
self
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn expect_recorded_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
|
||||
let actual = self.input.read_with(&self.cx, |input, _| {
|
||||
input
|
||||
.keystrokes()
|
||||
.iter()
|
||||
.map(|keystroke| keystroke.inner().clone())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
Self::expect_keystrokes_equal(&actual, expected);
|
||||
self
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
|
||||
let actual = self
|
||||
|
|
@ -1126,6 +1139,82 @@ mod tests {
|
|||
KeystrokeInputTestHelper::new(cx)
|
||||
}
|
||||
|
||||
fn init_dispatch_test(cx: &mut TestAppContext, binding: &str) -> KeystrokeInputTestHelper {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
cx.bind_keys([gpui::KeyBinding::new(
|
||||
binding,
|
||||
ClearKeystrokes,
|
||||
Some(KEY_CONTEXT_VALUE),
|
||||
)]);
|
||||
});
|
||||
let window = cx.add_window(|window, cx| KeystrokeInput::new(None, window, cx));
|
||||
let input = window.root(cx).expect("keystroke input window");
|
||||
let mut helper = KeystrokeInputTestHelper {
|
||||
input,
|
||||
current_modifiers: Modifiers::default(),
|
||||
cx: VisualTestContext::from_window(window.into(), cx),
|
||||
};
|
||||
helper.cx.update(|window, _| window.activate_window());
|
||||
helper.cx.run_until_parked();
|
||||
helper.cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
helper.start_recording();
|
||||
helper.cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
helper.input.read_with(&helper.cx, |input, _| {
|
||||
assert!(input.intercept_subscription.is_some());
|
||||
});
|
||||
helper
|
||||
}
|
||||
|
||||
fn simulate_standalone_shift(cx: &mut VisualTestContext) {
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
}
|
||||
|
||||
fn simulate_shift_f1(cx: &mut VisualTestContext) {
|
||||
let keystroke = Keystroke::parse("shift-f1").expect("valid keystroke");
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
cx.simulate_event(gpui::KeyDownEvent {
|
||||
keystroke: keystroke.clone(),
|
||||
is_held: false,
|
||||
prefer_character_input: false,
|
||||
});
|
||||
cx.simulate_event(gpui::KeyUpEvent { keystroke });
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_dispatch_records_standalone_shift_without_running_bound_action(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let mut helper = init_dispatch_test(cx, "shift");
|
||||
|
||||
helper.cx.simulate_modifiers_change(Modifiers::shift());
|
||||
helper.expect_recorded_keystrokes(&[]);
|
||||
helper.cx.simulate_modifiers_change(Modifiers::none());
|
||||
helper.expect_recorded_keystrokes(&["shift"]);
|
||||
|
||||
helper.stop_recording();
|
||||
helper.cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
simulate_standalone_shift(&mut helper.cx);
|
||||
helper.expect_empty();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_dispatch_records_shift_f1_without_extra_shift_or_bound_action(cx: &mut TestAppContext) {
|
||||
let mut helper = init_dispatch_test(cx, "shift-f1");
|
||||
|
||||
simulate_shift_f1(&mut helper.cx);
|
||||
helper.expect_recorded_keystrokes(&["shift-f1"]);
|
||||
|
||||
helper.stop_recording();
|
||||
helper.cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
simulate_shift_f1(&mut helper.cx);
|
||||
helper.expect_empty();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_basic_keystroke_input(cx: &mut TestAppContext) {
|
||||
init_test(cx)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,72 @@ async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
|
|||
cx.assert_editor_state("hjklˇ");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_unbound_standalone_modifiers_preserve_operator(cx: &mut TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.update_editor(|_, window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
|
||||
for temporary_normal in [false, true] {
|
||||
let expected_mode = if temporary_normal {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
};
|
||||
for modifiers in [
|
||||
Modifiers::shift(),
|
||||
Modifiers::control(),
|
||||
Modifiers::alt(),
|
||||
Modifiers::command(),
|
||||
Modifiers::function(),
|
||||
] {
|
||||
if temporary_normal {
|
||||
cx.set_state("ˇone two", Mode::Insert);
|
||||
cx.simulate_keystrokes("ctrl-o");
|
||||
} else {
|
||||
cx.set_state("ˇone two", Mode::Normal);
|
||||
}
|
||||
cx.simulate_keystrokes("d");
|
||||
assert_eq!(cx.active_operator(), Some(crate::state::Operator::Delete));
|
||||
|
||||
cx.simulate_modifiers_change(modifiers);
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
assert_eq!(
|
||||
cx.active_operator(),
|
||||
Some(crate::state::Operator::Delete),
|
||||
"modifier tap with {modifiers:?}"
|
||||
);
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
|
||||
cx.simulate_keystrokes("w");
|
||||
cx.assert_editor_state("ˇtwo");
|
||||
assert_eq!(cx.mode(), expected_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bound_standalone_modifier_updates_operator(cx: &mut TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.update_editor(|_, window, cx| {
|
||||
window.activate_window();
|
||||
cx.bind_keys([KeyBinding::new(
|
||||
"shift",
|
||||
editor::actions::MoveRight,
|
||||
Some("Editor"),
|
||||
)]);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.set_state("ˇone two", Mode::Normal);
|
||||
cx.simulate_keystrokes("d");
|
||||
assert_eq!(cx.active_operator(), Some(crate::state::Operator::Delete));
|
||||
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
cx.assert_editor_state("oˇne two");
|
||||
}
|
||||
|
||||
#[perf]
|
||||
#[gpui::test]
|
||||
async fn test_neovim(cx: &mut gpui::TestAppContext) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue