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:
Smit Barmase 2026-09-23 03:59:20 +00:00 • committed by GitHub
parent 5299a14401
commit ba42ab9d98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 677 additions and 44 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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| {

View file

@ -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,
);

View file

@ -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#"[

View file

@ -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)

View file

@ -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) {