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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user