gpui: Only time out multi-stroke bindings when current prefix matches (#42659)

Part One for Resolving #10910

### Summary
Typing prefix (partial keybinding) will behave like Vim. No timeout
until you either finish the sequence or hit Escape, while ambiguous
sequences still auto-resolve after 1s.

### Description
This follow-up tweaks the which-key system first part groundwork so our
timeout behavior matches Vim’s expectations. Then we can implement the
UI part in the next step (reference latest comments in
https://github.com/zed-industries/zed/pull/34798)
- `DispatchResult` now reports when the current keystrokes are already a
complete binding in the active context stack (`pending_has_binding`). We
only start the 1s flush timer in that case. Pure prefixes or sequences
that only match in other contexts—stay pending indefinitely, so
leader-style combos like `space f g` no longer evaporate after a second.
- `Window::dispatch_key_event` cancels any prior timer before scheduling
a new one and only spawns the background flush task when
`pending_has_binding` is true. If there’s no matching binding, we keep
the pending keystrokes and rely on an explicit Escape or more typing to
resolve them.

Release Notes:

- Fixed multi-stroke keybindings so only ambiguous prefixes auto-trigger
after 1 s; unmatched prefixes now stay pending until canceled, matching
Vim-style leader behavior.
This commit is contained in:
Xipeng Jin
2025-11-20 19:42:56 -07:00
committed by GitHub
parent 2b9eeb9a30
commit b3ebcef5c6
4 changed files with 94 additions and 32 deletions
+3 -3
View File
@@ -6551,12 +6551,12 @@ async fn test_pane_split_left(cx: &mut TestAppContext) {
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 2);
});
cx.simulate_keystrokes("cmd-k");
// sleep for longer than the timeout in keyboard shortcut handling
// to verify that it doesn't fire in this case.
// Sleep past the historical timeout to ensure the multi-stroke binding
// still fires now that unambiguous prefixes no longer auto-expire.
cx.executor().advance_clock(Duration::from_secs(2));
cx.simulate_keystrokes("left");
workspace.update(cx, |workspace, cx| {
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 2);
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 3);
});
}
+1 -7
View File
@@ -22165,13 +22165,7 @@ impl Editor {
.pending_input_keystrokes()
.into_iter()
.flatten()
.filter_map(|keystroke| {
if keystroke.modifiers.is_subset_of(&Modifiers::shift()) {
keystroke.key_char.clone()
} else {
None
}
})
.filter_map(|keystroke| keystroke.key_char.clone())
.collect();
if !self.input_enabled || self.read_only || !self.focus_handle.is_focused(window) {
+48 -1
View File
@@ -121,6 +121,7 @@ pub(crate) struct Replay {
#[derive(Default, Debug)]
pub(crate) struct DispatchResult {
pub(crate) pending: SmallVec<[Keystroke; 1]>,
pub(crate) pending_has_binding: bool,
pub(crate) bindings: SmallVec<[KeyBinding; 1]>,
pub(crate) to_replay: SmallVec<[Replay; 1]>,
pub(crate) context_stack: Vec<KeyContext>,
@@ -480,6 +481,7 @@ impl DispatchTree {
if pending {
return DispatchResult {
pending: input,
pending_has_binding: !bindings.is_empty(),
context_stack,
..Default::default()
};
@@ -608,9 +610,11 @@ impl DispatchTree {
#[cfg(test)]
mod tests {
use crate::{
self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style,
self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId,
Keystroke, LayoutId, Style,
};
use core::panic;
use smallvec::SmallVec;
use std::{cell::RefCell, ops::Range, rc::Rc};
use crate::{
@@ -676,6 +680,49 @@ mod tests {
assert!(keybinding[0].action.partial_eq(&TestAction))
}
#[test]
fn test_pending_has_binding_state() {
let bindings = vec![
KeyBinding::new("ctrl-b h", TestAction, None),
KeyBinding::new("space", TestAction, Some("ContextA")),
KeyBinding::new("space f g", TestAction, Some("ContextB")),
];
let keymap = Rc::new(RefCell::new(Keymap::new(bindings)));
let mut registry = ActionRegistry::default();
registry.load_action::<TestAction>();
let mut tree = DispatchTree::new(keymap, Rc::new(registry));
type DispatchPath = SmallVec<[super::DispatchNodeId; 32]>;
fn dispatch(
tree: &mut DispatchTree,
pending: SmallVec<[Keystroke; 1]>,
key: &str,
path: &DispatchPath,
) -> DispatchResult {
tree.dispatch_key(pending, Keystroke::parse(key).unwrap(), path)
}
let dispatch_path: DispatchPath = SmallVec::new();
let result = dispatch(&mut tree, SmallVec::new(), "ctrl-b", &dispatch_path);
assert_eq!(result.pending.len(), 1);
assert!(!result.pending_has_binding);
let result = dispatch(&mut tree, result.pending, "h", &dispatch_path);
assert_eq!(result.pending.len(), 0);
assert_eq!(result.bindings.len(), 1);
assert!(!result.pending_has_binding);
let node_id = tree.push_node();
tree.set_key_context(KeyContext::parse("ContextB").unwrap());
tree.pop_node();
let dispatch_path = tree.dispatch_path(node_id);
let result = dispatch(&mut tree, SmallVec::new(), "space", &dispatch_path);
assert_eq!(result.pending.len(), 1);
assert!(!result.pending_has_binding);
}
#[crate::test]
fn test_input_handler_pending(cx: &mut TestAppContext) {
#[derive(Clone)]
+42 -21
View File
@@ -909,6 +909,7 @@ struct PendingInput {
keystrokes: SmallVec<[Keystroke; 1]>,
focus: Option<FocusId>,
timer: Option<Task<()>>,
needs_timeout: bool,
}
pub(crate) struct ElementStateBox {
@@ -3896,32 +3897,52 @@ impl Window {
}
if !match_result.pending.is_empty() {
currently_pending.timer.take();
currently_pending.keystrokes = match_result.pending;
currently_pending.focus = self.focus;
currently_pending.timer = Some(self.spawn(cx, async move |cx| {
cx.background_executor.timer(Duration::from_secs(1)).await;
cx.update(move |window, cx| {
let Some(currently_pending) = window
.pending_input
.take()
.filter(|pending| pending.focus == window.focus)
else {
return;
};
let node_id = window.focus_node_id_in_rendered_frame(window.focus);
let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id);
let text_input_requires_timeout = event
.downcast_ref::<KeyDownEvent>()
.filter(|key_down| key_down.keystroke.key_char.is_some())
.and_then(|_| self.platform_window.take_input_handler())
.map_or(false, |mut input_handler| {
let accepts = input_handler.accepts_text_input(self, cx);
self.platform_window.set_input_handler(input_handler);
accepts
});
let to_replay = window
.rendered_frame
.dispatch_tree
.flush_dispatch(currently_pending.keystrokes, &dispatch_path);
currently_pending.needs_timeout |=
match_result.pending_has_binding || text_input_requires_timeout;
window.pending_input_changed(cx);
window.replay_pending_input(to_replay, cx)
})
.log_err();
}));
if currently_pending.needs_timeout {
currently_pending.timer = Some(self.spawn(cx, async move |cx| {
cx.background_executor.timer(Duration::from_secs(1)).await;
cx.update(move |window, cx| {
let Some(currently_pending) = window
.pending_input
.take()
.filter(|pending| pending.focus == window.focus)
else {
return;
};
let node_id = window.focus_node_id_in_rendered_frame(window.focus);
let dispatch_path =
window.rendered_frame.dispatch_tree.dispatch_path(node_id);
let to_replay = window
.rendered_frame
.dispatch_tree
.flush_dispatch(currently_pending.keystrokes, &dispatch_path);
window.pending_input_changed(cx);
window.replay_pending_input(to_replay, cx)
})
.log_err();
}));
} else {
currently_pending.timer = None;
}
self.pending_input = Some(currently_pending);
self.pending_input_changed(cx);
cx.propagate_event = false;