mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-20 11:46:49 +00:00
which_key: Add keyboard shortcut to show pending bindings (#64803)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / check_remote_server_linux_x86_64 (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_remote_server_mac_aarch64 (push) Blocked by required conditions
run_tests / check_remote_server_windows_x86_64 (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / check_remote_server_linux_x86_64 (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_remote_server_mac_aarch64 (push) Blocked by required conditions
run_tests / check_remote_server_windows_x86_64 (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Follow-up #63343 Depends on #64859 The pending keystrokes indicator lists the bindings that can complete a chord, but only on hover. This PR adds `which_key::ShowPendingBindings`, bound to `alt-/`, which opens that list from the keyboard while a chord is pending. For example, press `cmd-k` (`ctrl-k` on Linux/Windows), then `alt-/`. The list opens without moving focus and stays open until the chord completes or is cleared, even if the pointer moves away. It follows the chord as you type, and timed chords stay paused while it's open. With no chord pending, `alt-/` does nothing. **Why `alt-/`?** `/` shares a key with `?`, which is a common "help" key, and `alt-/` isn't bound in any default or base keymap. When no chord is pending, `alt-/` behaves as before. It only opens this popup while a chord is pending. On macOS, `alt-/` normally types `÷`, and it still does when no chord is pending. The shortcut only kicks in mid-chord. The one thing we lose is that `cmd-k` then `alt-/` used to cancel the chord and type `÷`, and now it opens the popover instead. I think that's rare enough to be okay, and the key can be rebound or unbound. It can be unbound or rebound to another key. One limitation is that it must be bound to a single keystroke. A multi-keystroke binding would need its own pending tracking on top of the chord that's already pending, and we don't want to add a special pending state just for this. https://github.com/user-attachments/assets/0a26b0ad-cd7b-49d9-b466-cce54e02bf80 Release Notes: - Added `alt-/` to show the bindings that can complete a pending multi-keystroke shortcut.
This commit is contained in:
parent
86c20cc911
commit
00def035de
7 changed files with 664 additions and 15 deletions
|
|
@ -634,6 +634,7 @@
|
|||
{
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"alt-/": "which_key::ShowPendingBindings",
|
||||
// Move focus between the major regions of the window (editor, open
|
||||
// panels, status bar). Accessibility navigation; mirrors VS Code's
|
||||
// "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation
|
||||
|
|
|
|||
|
|
@ -696,6 +696,7 @@
|
|||
"context": "Workspace",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"alt-/": "which_key::ShowPendingBindings",
|
||||
// Move focus between the major regions of the window (editor, open
|
||||
// panels, status bar). Accessibility navigation; mirrors VS Code's
|
||||
// "Focus Next/Previous Part". `cmd-f6` mirrors `f6` so region navigation
|
||||
|
|
|
|||
|
|
@ -634,6 +634,7 @@
|
|||
"context": "Workspace",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"alt-/": "which_key::ShowPendingBindings",
|
||||
// Move focus between the major regions of the window (editor, open
|
||||
// panels, status bar). Accessibility navigation; mirrors VS Code's
|
||||
// "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use gpui::{
|
||||
Action as _, Anchor, Animation, AnimationExt, App, Context, HoverListenerMode,
|
||||
KeybindingKeystroke, Render, ScrollHandle, Subscription, Task, Window, anchored, deferred,
|
||||
Action as _, Anchor, Animation, AnimationExt, App, Context, HoverListenerMode, InputPreference,
|
||||
KeybindingKeystroke, KeystrokeEvent, Render, ScrollHandle, Subscription, Task, Window,
|
||||
anchored, deferred,
|
||||
};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
|
@ -12,7 +13,7 @@ use vim_mode_setting::{HelixModeSetting, VimModeSetting};
|
|||
use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, item::ItemHandle};
|
||||
|
||||
use crate::{
|
||||
bindings_for_pending_input, map_pending_keystrokes,
|
||||
ShowPendingBindings, bindings_for_pending_input, map_pending_keystrokes,
|
||||
pending_bindings::{PendingBindingRow, PendingBindings, prepare_pending_bindings},
|
||||
which_key_settings::WhichKeySettings,
|
||||
};
|
||||
|
|
@ -34,6 +35,7 @@ struct PopoverState {
|
|||
indicator_pointer_over: bool,
|
||||
pointer_over: bool,
|
||||
visible: bool,
|
||||
opened_by_keyboard: bool,
|
||||
hide_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ impl PendingKeystrokesIndicator {
|
|||
if this.refresh_render_state(window, cx) {
|
||||
cx.notify();
|
||||
}
|
||||
this.update_pointer_over_state(window, cx);
|
||||
this.update_popover_state(window, cx);
|
||||
});
|
||||
|
||||
let mut enabled = Self::enabled(cx);
|
||||
|
|
@ -81,12 +83,13 @@ impl PendingKeystrokesIndicator {
|
|||
if !new_popover_enabled {
|
||||
this.popover.pointer_over = false;
|
||||
this.popover.visible = false;
|
||||
this.popover.opened_by_keyboard = false;
|
||||
this.popover.hide_task.take();
|
||||
}
|
||||
if this.refresh_render_state(window, cx) {
|
||||
cx.notify();
|
||||
}
|
||||
this.update_pointer_over_state(window, cx);
|
||||
this.update_popover_state(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
|
|
@ -131,9 +134,15 @@ impl PendingKeystrokesIndicator {
|
|||
if pending_keys_changed {
|
||||
self.popover_scroll_handle.set_offset(Default::default());
|
||||
}
|
||||
// Pausing or resuming the timer also notifies observers.
|
||||
// Only a change in pending keys should close the popover early.
|
||||
if self.popover.visible && !self.popover.is_pointer_over() && pending_keys_changed {
|
||||
// Pausing or resuming the timeout also notifies observers, so close a hover-opened
|
||||
// popover early only when the pending keys change. A keyboard-opened popover stays open
|
||||
// while any input is pending, even if an unmatched key resolves one chord and starts
|
||||
// another.
|
||||
if self.popover.visible
|
||||
&& !self.popover.opened_by_keyboard
|
||||
&& !self.popover.is_pointer_over()
|
||||
&& pending_keys_changed
|
||||
{
|
||||
self.popover = PopoverState::default();
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +178,7 @@ impl PendingKeystrokesIndicator {
|
|||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.popover.indicator_pointer_over = pointer_over;
|
||||
self.update_pointer_over_state(window, cx);
|
||||
self.update_popover_state(window, cx);
|
||||
}
|
||||
|
||||
fn set_popover_pointer_over(
|
||||
|
|
@ -179,15 +188,15 @@ impl PendingKeystrokesIndicator {
|
|||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.popover.pointer_over = pointer_over;
|
||||
self.update_pointer_over_state(window, cx);
|
||||
self.update_popover_state(window, cx);
|
||||
}
|
||||
|
||||
fn update_pointer_over_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn update_popover_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.render_state.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.popover.is_pointer_over() {
|
||||
if self.popover.opened_by_keyboard || self.popover.is_pointer_over() {
|
||||
self.popover.hide_task.take();
|
||||
let was_visible = self.popover.visible;
|
||||
self.popover.visible = Self::popover_enabled(cx);
|
||||
|
|
@ -215,6 +224,38 @@ impl PendingKeystrokesIndicator {
|
|||
window.set_pending_input_timeout_paused(&cx.entity(), false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_intercepted_keystroke(
|
||||
&mut self,
|
||||
event: &KeystrokeEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if event.input_preference == InputPreference::CharacterInput
|
||||
|| !window.has_pending_keystrokes()
|
||||
|| !Self::enabled(cx)
|
||||
|| !Self::popover_enabled(cx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the highest-precedence binding counts, so user overrides can disable the shortcut.
|
||||
let (bindings, _) = cx
|
||||
.key_bindings()
|
||||
.borrow()
|
||||
.bindings_for_input(std::slice::from_ref(&event.keystroke), &event.context_stack);
|
||||
if !bindings
|
||||
.first()
|
||||
.is_some_and(|binding| binding.action().partial_eq(&ShowPendingBindings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
self.popover.opened_by_keyboard = true;
|
||||
self.update_popover_state(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PendingKeystrokesIndicator {
|
||||
|
|
@ -223,6 +264,20 @@ impl Render for PendingKeystrokesIndicator {
|
|||
return div().hidden().into_any_element();
|
||||
};
|
||||
|
||||
if Self::popover_enabled(cx) {
|
||||
let window_handle = window.window_handle();
|
||||
let listener = cx.listener(move |this, event: &KeystrokeEvent, window, cx| {
|
||||
if window.window_handle() == window_handle {
|
||||
this.handle_intercepted_keystroke(event, window, cx);
|
||||
}
|
||||
});
|
||||
// Inactive workspaces in the same window keep their indicators alive. Storing the
|
||||
// subscription in element state ensures only the rendered indicator intercepts input.
|
||||
window.use_keyed_state("pending-keystrokes-interceptor", cx, |_, cx| {
|
||||
cx.intercept_keystrokes(listener)
|
||||
});
|
||||
}
|
||||
|
||||
let button = ButtonLike::new("pending-keystrokes-indicator")
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(zed_actions::dev::OpenKeyContextView.boxed_clone(), cx);
|
||||
|
|
@ -351,13 +406,17 @@ impl StatusItemView for PendingKeystrokesIndicator {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use command_palette::humanize_action_name;
|
||||
use gpui::{
|
||||
Entity, FocusHandle, KeyBinding, Modifiers, TestAppContext, VisualTestContext, actions,
|
||||
point,
|
||||
Bounds, Entity, FocusHandle, InputHandler, KeyBinding, KeyDownEvent, Keystroke, Modifiers,
|
||||
Pixels, PlatformInput, Point, TestAppContext, UTF16Selection, VisualTestContext, actions,
|
||||
canvas, point,
|
||||
};
|
||||
|
||||
actions!(
|
||||
|
|
@ -400,10 +459,90 @@ mod tests {
|
|||
]
|
||||
}
|
||||
|
||||
fn binding(keystrokes: &str, action: impl gpui::Action) -> KeyBinding {
|
||||
KeyBinding::new(keystrokes, action, Some("PendingKeystrokesIndicatorTest"))
|
||||
}
|
||||
|
||||
/// Binds each keystroke sequence to the action the test view counts.
|
||||
fn counted_bindings<const N: usize>(keystrokes: [&str; N]) -> [KeyBinding; N] {
|
||||
keystrokes.map(|keystrokes| binding(keystrokes, zed_actions::dev::OpenKeyContextView))
|
||||
}
|
||||
|
||||
struct TestView {
|
||||
focus_handle: FocusHandle,
|
||||
indicator: Entity<PendingKeystrokesIndicator>,
|
||||
open_key_context_view_count: Rc<Cell<usize>>,
|
||||
input_text: Option<Rc<RefCell<String>>>,
|
||||
}
|
||||
|
||||
/// Appends inserted text to a shared buffer so tests can observe text input.
|
||||
struct TestInputHandler(Rc<RefCell<String>>);
|
||||
|
||||
impl InputHandler for TestInputHandler {
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<UTF16Selection> {
|
||||
None
|
||||
}
|
||||
|
||||
fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
_: Range<usize>,
|
||||
_: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
replacement_range: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) {
|
||||
assert!(replacement_range.is_none());
|
||||
self.0.borrow_mut().push_str(text);
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
_: Option<Range<usize>>,
|
||||
_: &str,
|
||||
_: Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) {
|
||||
unreachable!("test does not compose text");
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, _: &mut App) {}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_: Range<usize>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
|
|
@ -461,6 +600,11 @@ mod tests {
|
|||
settings::init(cx);
|
||||
WhichKeySettings::register(cx);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
cx.bind_keys([KeyBinding::new(
|
||||
"alt-/",
|
||||
ShowPendingBindings,
|
||||
Some("PendingKeystrokesIndicatorTest"),
|
||||
)]);
|
||||
cx.bind_keys(bindings);
|
||||
});
|
||||
|
||||
|
|
@ -471,6 +615,7 @@ mod tests {
|
|||
focus_handle: cx.focus_handle(),
|
||||
indicator: cx.new(|cx| PendingKeystrokesIndicator::new(window, cx)),
|
||||
open_key_context_view_count,
|
||||
input_text: None,
|
||||
}
|
||||
});
|
||||
let (focus_handle, indicator) = test_view.read_with(cx, |test_view, _| {
|
||||
|
|
@ -512,12 +657,55 @@ mod tests {
|
|||
move_pointer_outside(cx);
|
||||
}
|
||||
|
||||
fn popover_rendered(cx: &mut VisualTestContext) -> bool {
|
||||
cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_some()
|
||||
}
|
||||
|
||||
/// Advances the clock well past the default 1s pending input timeout.
|
||||
fn advance_past_pending_timeout(cx: &mut VisualTestContext) {
|
||||
cx.executor().advance_clock(Duration::from_secs(5));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn window_pending_keystrokes(cx: &mut VisualTestContext) -> Vec<Keystroke> {
|
||||
cx.update(|window, _| {
|
||||
window
|
||||
.pending_input_keystrokes()
|
||||
.map(<[Keystroke]>::to_vec)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn window_timeout_paused(cx: &mut VisualTestContext) -> Option<bool> {
|
||||
cx.update(|window, _| Some(window.pending_input()?.timeout()?.is_paused()))
|
||||
}
|
||||
|
||||
fn set_user_settings(cx: &mut VisualTestContext, settings: &str) {
|
||||
cx.update(|_, cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store
|
||||
.set_user_settings(settings, cx)
|
||||
.expect("valid test settings");
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
impl Render for TestView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.key_context("PendingKeystrokesIndicatorTest")
|
||||
.track_focus(&self.focus_handle)
|
||||
.when_some(self.input_text.clone(), |this, input_text| {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
this.child(canvas(
|
||||
|_, _, _| {},
|
||||
move |_, _, window, cx| {
|
||||
window.handle_input(&focus_handle, TestInputHandler(input_text), cx);
|
||||
},
|
||||
))
|
||||
})
|
||||
.on_action(|_: &ShorterBinding, _, _| {})
|
||||
.on_action(|_: &LongerBinding, _, _| {})
|
||||
.on_action(|_: &LongestBinding, _, _| {})
|
||||
|
|
@ -547,6 +735,11 @@ mod tests {
|
|||
assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none()));
|
||||
assert!(cx.debug_bounds("PENDING_KEYSTROKES_INDICATOR").is_none());
|
||||
|
||||
// The help shortcut does nothing when no input is pending.
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(!popover_rendered(cx));
|
||||
|
||||
// "x" starts no chord in this keymap, so ordinary input must keep the indicator hidden.
|
||||
cx.simulate_keystrokes("x");
|
||||
cx.run_until_parked();
|
||||
|
|
@ -556,6 +749,419 @@ mod tests {
|
|||
assert!(cx.debug_bounds("PENDING_KEYSTROKES_INDICATOR").is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_pending_bindings_shortcut_respects_character_input(cx: &mut TestAppContext) {
|
||||
// Whether the focused input accepts text is covered by GPUI's own preference tests.
|
||||
for prefer_character_input in [true, false] {
|
||||
let (indicator, action_count, cx) = setup_indicator_test(
|
||||
cx,
|
||||
counted_bindings(["j k"])
|
||||
.into_iter()
|
||||
.chain([binding("ctrl-alt-a", ShowPendingBindings)]),
|
||||
);
|
||||
let text = Rc::new(RefCell::new(String::new()));
|
||||
cx.update(|window, cx| {
|
||||
window
|
||||
.root::<TestView>()
|
||||
.flatten()
|
||||
.expect("test view")
|
||||
.update(cx, |view, cx| {
|
||||
view.input_text = Some(text.clone());
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
cx.simulate_keystrokes("j");
|
||||
cx.run_until_parked();
|
||||
let pending_keystrokes = window_pending_keystrokes(cx);
|
||||
assert_eq!(
|
||||
pending_keystrokes
|
||||
.iter()
|
||||
.map(Keystroke::unparse)
|
||||
.collect::<Vec<_>>(),
|
||||
["j"]
|
||||
);
|
||||
assert!(text.borrow().is_empty());
|
||||
|
||||
// simulate_keystrokes hardcodes prefer_character_input to false.
|
||||
let result = cx.update(|window, cx| {
|
||||
window.dispatch_event(
|
||||
PlatformInput::KeyDown(KeyDownEvent {
|
||||
keystroke: Keystroke {
|
||||
key_char: Some("ą".into()),
|
||||
..Keystroke::parse("ctrl-alt-a").expect("valid keystroke")
|
||||
},
|
||||
is_held: false,
|
||||
prefer_character_input,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
if prefer_character_input {
|
||||
assert!(result.propagate);
|
||||
assert_eq!(*text.borrow(), "j");
|
||||
assert!(window_pending_keystrokes(cx).is_empty());
|
||||
assert!(!indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
assert!(!popover_rendered(cx));
|
||||
|
||||
// Platforms insert the character when GPUI leaves the key event unconsumed.
|
||||
cx.simulate_input("ą");
|
||||
assert_eq!(*text.borrow(), "ją");
|
||||
} else {
|
||||
assert!(!result.propagate);
|
||||
assert!(text.borrow().is_empty());
|
||||
assert_eq!(window_pending_keystrokes(cx), pending_keystrokes);
|
||||
assert!(popover_rendered(cx));
|
||||
}
|
||||
assert_eq!(action_count.get(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_disabled_shortcut_lets_longer_chord_complete(cx: &mut TestAppContext) {
|
||||
// Only `ctrl-b alt-/` is counted, so a count of 1 means the chord completed.
|
||||
let (_, action_count, cx) = setup_indicator_test(
|
||||
cx,
|
||||
counted_bindings(["ctrl-b alt-/"])
|
||||
.into_iter()
|
||||
.chain([binding("alt-/", gpui::NoAction)]),
|
||||
);
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert!(!popover_rendered(cx));
|
||||
cx.update(|window, _| assert!(!window.has_pending_keystrokes()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_cancels_hover_dismissal(cx: &mut TestAppContext) {
|
||||
let (indicator, action_count, cx) =
|
||||
setup_indicator_test(cx, counted_bindings(["ctrl-b", "ctrl-b h"]));
|
||||
start_popover_dismissal(cx);
|
||||
cx.executor().advance_clock(Duration::from_millis(100));
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
advance_past_pending_timeout(cx);
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(action_count.get(), 0);
|
||||
indicator.read_with(cx, |indicator, _| {
|
||||
assert!(indicator.popover.opened_by_keyboard);
|
||||
assert!(indicator.popover.hide_task.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_preserves_and_completes_pending_input(cx: &mut TestAppContext) {
|
||||
// `ctrl-b alt-/` is bound too, but while `ctrl-b` is pending the help shortcut wins.
|
||||
let (indicator, action_count, cx) = setup_indicator_test(
|
||||
cx,
|
||||
counted_bindings(["ctrl-b", "ctrl-b h", "ctrl-b h j", "ctrl-b alt-/"]),
|
||||
);
|
||||
let focus_handle = cx.update(|window, cx| window.focused(cx));
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
let pending_keystrokes = window_pending_keystrokes(cx);
|
||||
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(cx.update(|window, cx| window.focused(cx)), focus_handle);
|
||||
assert_eq!(window_pending_keystrokes(cx), pending_keystrokes);
|
||||
assert_eq!(action_count.get(), 0);
|
||||
|
||||
// Moving the pointer away or pressing the shortcut again must neither dismiss the
|
||||
// popover nor let the timeout fire.
|
||||
move_pointer_over_popover(cx);
|
||||
move_pointer_outside(cx);
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
advance_past_pending_timeout(cx);
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(action_count.get(), 0);
|
||||
|
||||
// The popover follows the chord as it advances, keeping the timeout paused.
|
||||
cx.simulate_keystrokes("h");
|
||||
cx.run_until_parked();
|
||||
let snapshot = indicator
|
||||
.read_with(cx, |indicator, _| indicator_snapshot(indicator))
|
||||
.expect("pending input snapshot");
|
||||
assert_eq!(snapshot.keystrokes, ["ctrl-b", "h"]);
|
||||
assert!(snapshot.popover_visible);
|
||||
assert!(snapshot.timeout_paused);
|
||||
assert_eq!(
|
||||
snapshot.bindings,
|
||||
vec![(
|
||||
vec!["j".to_string()],
|
||||
humanize_action_name(zed_actions::dev::OpenKeyContextView.name()),
|
||||
)]
|
||||
);
|
||||
|
||||
advance_past_pending_timeout(cx);
|
||||
assert_eq!(action_count.get(), 0);
|
||||
|
||||
// Completing the chord runs its action and closes the popover.
|
||||
cx.simulate_keystrokes("j");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert!(!popover_rendered(cx));
|
||||
cx.update(|window, _| assert!(!window.has_pending_keystrokes()));
|
||||
|
||||
// The next chord starts with the popover closed.
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
cx.run_until_parked();
|
||||
assert!(!popover_rendered(cx));
|
||||
let snapshot = indicator
|
||||
.read_with(cx, |indicator, _| indicator_snapshot(indicator))
|
||||
.expect("new pending input snapshot");
|
||||
assert!(!snapshot.timeout_paused);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_stays_open_when_pending_chord_restarts(cx: &mut TestAppContext) {
|
||||
let (indicator, action_count, cx) =
|
||||
setup_indicator_test(cx, counted_bindings(["ctrl-b", "ctrl-b h"]));
|
||||
|
||||
// The second `ctrl-b` doesn't continue the chord, so the first `ctrl-b` runs its action
|
||||
// and a new chord starts. The keyboard-opened popover stays open across the restart.
|
||||
cx.simulate_keystrokes("ctrl-b alt-/ ctrl-b");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(
|
||||
window_pending_keystrokes(cx)
|
||||
.iter()
|
||||
.map(Keystroke::unparse)
|
||||
.collect::<Vec<_>>(),
|
||||
["ctrl-b"]
|
||||
);
|
||||
assert_eq!(window_timeout_paused(cx), Some(true));
|
||||
assert!(indicator.read_with(cx, |indicator, _| indicator.popover.opened_by_keyboard));
|
||||
|
||||
advance_past_pending_timeout(cx);
|
||||
assert_eq!(action_count.get(), 1);
|
||||
assert!(popover_rendered(cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_respects_overrides_and_unbinding(cx: &mut TestAppContext) {
|
||||
// Each override is bound after the default `alt-/`, so it takes precedence.
|
||||
for (override_binding, expected_action_count) in [
|
||||
(binding("alt-/", zed_actions::dev::OpenKeyContextView), 1),
|
||||
(binding("alt-/", gpui::NoAction), 0),
|
||||
(
|
||||
binding("alt-/", gpui::Unbind(ShowPendingBindings.name().into())),
|
||||
0,
|
||||
),
|
||||
] {
|
||||
cx.update(|cx| cx.clear_key_bindings());
|
||||
let (indicator, action_count, cx) =
|
||||
setup_indicator_test(cx, timed_bindings().into_iter().chain([override_binding]));
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(!indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
assert!(!popover_rendered(cx));
|
||||
assert_eq!(action_count.get(), expected_action_count);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_shortcut_ignores_inactive_context_bindings(cx: &mut TestAppContext) {
|
||||
// The `x` binding in an inactive context must not shadow the active help shortcut.
|
||||
let (indicator, _, cx) = setup_indicator_test(
|
||||
cx,
|
||||
timed_bindings().into_iter().chain([
|
||||
binding("alt-/", gpui::NoAction),
|
||||
binding("x", ShowPendingBindings),
|
||||
KeyBinding::new("x", ShorterBinding, Some("InactiveContext")),
|
||||
]),
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b x");
|
||||
cx.run_until_parked();
|
||||
assert!(popover_rendered(cx));
|
||||
let snapshot = indicator
|
||||
.read_with(cx, |indicator, _| indicator_snapshot(indicator))
|
||||
.expect("pending input snapshot");
|
||||
assert_eq!(snapshot.keystrokes, ["ctrl-b"]);
|
||||
assert!(snapshot.timeout_paused);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_can_use_a_standalone_modifier_shortcut(cx: &mut TestAppContext) {
|
||||
let (indicator, action_count, cx) = setup_indicator_test(
|
||||
cx,
|
||||
counted_bindings(["ctrl-b", "ctrl-b h"])
|
||||
.into_iter()
|
||||
.chain([binding("shift", ShowPendingBindings)]),
|
||||
);
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
let pending_keystrokes = window_pending_keystrokes(cx);
|
||||
|
||||
// Standalone modifier bindings match on release, not on press.
|
||||
cx.simulate_modifiers_change(Modifiers::shift());
|
||||
cx.run_until_parked();
|
||||
assert!(!popover_rendered(cx));
|
||||
|
||||
cx.simulate_modifiers_change(Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(window_pending_keystrokes(cx), pending_keystrokes);
|
||||
assert_eq!(window_timeout_paused(cx), Some(true));
|
||||
assert!(indicator.read_with(cx, |indicator, _| indicator.popover.opened_by_keyboard));
|
||||
assert_eq!(action_count.get(), 0);
|
||||
|
||||
advance_past_pending_timeout(cx);
|
||||
assert!(popover_rendered(cx));
|
||||
assert_eq!(action_count.get(), 0);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_multistroke_shortcut_binding_does_not_open_popover(cx: &mut TestAppContext) {
|
||||
let (indicator, _, cx) =
|
||||
setup_indicator_test(cx, [binding("ctrl-b h", ShowPendingBindings)]);
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
cx.run_until_parked();
|
||||
assert!(!indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
|
||||
// Completing the binding dispatches the action normally, which doesn't open the popover.
|
||||
cx.simulate_keystrokes("h");
|
||||
cx.run_until_parked();
|
||||
assert!(!popover_rendered(cx));
|
||||
cx.update(|window, _| assert!(!window.has_pending_keystrokes()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_is_scoped_to_its_window(cx: &mut TestAppContext) {
|
||||
// Both indicators need pending input to render and register an interceptor.
|
||||
let (first_indicator, _, first_cx) = setup_indicator_test(cx, timed_bindings());
|
||||
first_cx.simulate_keystrokes("ctrl-b");
|
||||
first_cx.run_until_parked();
|
||||
let (second_indicator, _, cx) = setup_indicator_test(cx, timed_bindings());
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(second_indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
first_indicator.read_with(cx, |indicator, _| {
|
||||
assert!(indicator.render_state().is_some());
|
||||
assert!(!indicator.popover.visible);
|
||||
assert!(!indicator.popover.opened_by_keyboard);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_does_not_open_unrendered_indicator(cx: &mut TestAppContext) {
|
||||
// Like an indicator in an inactive workspace, the replacement indicator observes the
|
||||
// same window's pending input without being rendered.
|
||||
let (initial_indicator, _, cx) = setup_indicator_test(cx, timed_bindings());
|
||||
let replacement_indicator =
|
||||
cx.update(|window, cx| cx.new(|cx| PendingKeystrokesIndicator::new(window, cx)));
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(initial_indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
assert!(!replacement_indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
|
||||
// Once the replacement is the rendered indicator, only it responds to the shortcut.
|
||||
cx.simulate_keystrokes("h ctrl-b");
|
||||
cx.update(|window, cx| {
|
||||
window
|
||||
.root::<TestView>()
|
||||
.expect("test view type")
|
||||
.expect("window root")
|
||||
.update(cx, |view, cx| {
|
||||
view.indicator = replacement_indicator.clone();
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(replacement_indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
assert!(!initial_indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_keyboard_popover_pauses_new_timeout(cx: &mut TestAppContext) {
|
||||
// `ctrl-b` alone isn't bound, so pending `ctrl-b` has no timeout. Typing `h` matches
|
||||
// `ctrl-b h` while `ctrl-b h j` is still possible, starting a timeout that the open
|
||||
// popover must pause. GPUI keeps an already paused timeout paused as the chord advances,
|
||||
// so this is the only case that relies on the popover.
|
||||
let (indicator, _, cx) = setup_indicator_test(
|
||||
cx,
|
||||
[
|
||||
binding("ctrl-b h", ShorterBinding),
|
||||
binding("ctrl-b h j", LongerBinding),
|
||||
],
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.update(|window, _| {
|
||||
let pending_input = window.pending_input().expect("pending chord");
|
||||
assert!(pending_input.timeout().is_none());
|
||||
});
|
||||
|
||||
cx.simulate_keystrokes("h");
|
||||
cx.run_until_parked();
|
||||
advance_past_pending_timeout(cx);
|
||||
let snapshot = indicator
|
||||
.read_with(cx, |indicator, _| indicator_snapshot(indicator))
|
||||
.expect("pending input snapshot");
|
||||
assert_eq!(snapshot.keystrokes, ["ctrl-b", "h"]);
|
||||
assert!(snapshot.popover_visible);
|
||||
assert!(snapshot.timeout_paused);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_disabling_keyboard_popover_resumes_timeout(cx: &mut TestAppContext) {
|
||||
// Hiding the indicator and enabling which-key both disable the popover.
|
||||
for disabling_settings in [
|
||||
r#"{"status_bar":{"pending_keystrokes_indicator":false}}"#,
|
||||
r#"{"which_key":{"enabled":true}}"#,
|
||||
] {
|
||||
let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings());
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
cx.executor().advance_clock(Duration::from_millis(600));
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
advance_past_pending_timeout(cx);
|
||||
assert!(indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
|
||||
// The timeout resumes with the time that was left when the popover opened.
|
||||
set_user_settings(cx, disabling_settings);
|
||||
assert!(!indicator.read_with(cx, |indicator, _| indicator.popover.visible));
|
||||
cx.update(|window, cx| {
|
||||
let timeout = window
|
||||
.pending_input()
|
||||
.and_then(|pending_input| pending_input.timeout())
|
||||
.expect("resumed timeout");
|
||||
assert!(!timeout.is_paused());
|
||||
assert_eq!(timeout.remaining(cx), Duration::from_millis(400));
|
||||
});
|
||||
|
||||
advance_past_pending_timeout(cx);
|
||||
cx.update(|window, _| assert!(!window.has_pending_keystrokes()));
|
||||
|
||||
// The shortcut does nothing while the popover is disabled.
|
||||
cx.simulate_keystrokes("ctrl-b alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(!popover_rendered(cx));
|
||||
|
||||
// Re-enabling the popover mid-chord makes the shortcut work again.
|
||||
cx.simulate_keystrokes("ctrl-b");
|
||||
set_user_settings(cx, "{}");
|
||||
cx.simulate_keystrokes("alt-/");
|
||||
cx.run_until_parked();
|
||||
assert!(popover_rendered(cx));
|
||||
let snapshot = indicator
|
||||
.read_with(cx, |indicator, _| indicator_snapshot(indicator))
|
||||
.expect("pending input snapshot");
|
||||
assert!(snapshot.timeout_paused);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_indicator_tracks_pending_input(cx: &mut TestAppContext) {
|
||||
let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings());
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ use which_key_modal::WhichKeyModal;
|
|||
use which_key_settings::WhichKeySettings;
|
||||
use workspace::Workspace;
|
||||
|
||||
gpui::actions!(
|
||||
which_key,
|
||||
[
|
||||
/// Shows the bindings that can complete the pending key sequence. Must be bound to a
|
||||
/// single keystroke.
|
||||
ShowPendingBindings
|
||||
]
|
||||
);
|
||||
|
||||
pub(crate) struct PendingBinding {
|
||||
pub(crate) remaining_keystrokes: Vec<KeybindingKeystroke>,
|
||||
pub(crate) action_name: SharedString,
|
||||
|
|
|
|||
|
|
@ -5977,6 +5977,7 @@ mod tests {
|
|||
"toolchain",
|
||||
"variable_list",
|
||||
"vim",
|
||||
"which_key",
|
||||
"window",
|
||||
"workspace",
|
||||
"worktree_picker",
|
||||
|
|
|
|||
|
|
@ -179,6 +179,36 @@ Zed can also wait before inserting printable text when it might begin a multi-st
|
|||
|
||||
Whenever multi-stroke input is pending, an indicator with the pending keystrokes is shown in the status bar. Hovering it lists the bindings that could still match. If the input has a timeout, the indicator also shows a countdown, and hovering pauses the timeout so you can read the bindings. The timeout resumes with the same remaining duration when the pointer leaves. Without a timeout, the pending keystrokes are shown without a countdown. The indicator can be hidden with `{"status_bar": {"pending_keystrokes_indicator": false}}`. Vim and Helix modes continue to use their existing pending-key indicator instead.
|
||||
|
||||
While a key sequence (chord) is pending, press {#kb which_key::ShowPendingBindings}
|
||||
to open the indicator's binding list without moving focus. The list updates as
|
||||
you continue typing and stays open until the chord completes or is cleared.
|
||||
While it is open, the timeout stays paused, even if the pointer moves away.
|
||||
|
||||
The {#action which_key::ShowPendingBindings} action only works while a chord is
|
||||
pending, and only when bound to a single keystroke. While a chord is pending,
|
||||
it takes precedence over longer bindings that use the same keystroke, such as
|
||||
`ctrl-b alt-/` or `alt-/ x`. To use those bindings mid-chord, rebind or disable
|
||||
the shortcut.
|
||||
|
||||
Your chords that start with `alt-/`, such as `alt-/ x`, time out like any chord
|
||||
whose first keystroke is also bound. You can override or disable the default
|
||||
binding using the normal [keybinding precedence](#precedence); only disabling it
|
||||
removes that timeout. For example, add this to your `keymap.json` to move the
|
||||
shortcut to `alt-h`:
|
||||
|
||||
```json [keymap]
|
||||
[
|
||||
{
|
||||
"context": "Workspace",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"alt-/": null,
|
||||
"alt-h": "which_key::ShowPendingBindings"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
To also use the larger which-key menu, open the Settings Editor and search for
|
||||
`Show Which-key Menu`. The pending keystrokes indicator remains visible when the
|
||||
menu is enabled, and hovering it still pauses the timeout, but its binding list
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue