From d4424eec680e3243ed667e25f394c7eda6670bb4 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:53 +0000 Subject: [PATCH] which_key: Add a pending key binding indicator (#63343) This PR makes Zed's one-second key binding delay visible. Say both ctrl-w and ctrl-w left have bindings. After you press ctrl-w, Zed waits to see whether left follows. The PR shows that wait in the status bar with a countdown. Hovering the indicator shows which bindings can still match. It also adds a setting to hide the indicator. It does not change how Zed resolves key bindings. dyn-db84a840272e2f6250a3f527a59b1efd
dyn-8012278d030b41bc8ebfae1540504eff
file-5e6ffa90944faa294e7870f471b4e65b Release Notes: - Added a status bar countdown for pending multi-stroke key bindings. --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 4 + assets/settings/default.json | 6 + crates/gpui/src/key_dispatch.rs | 751 +++++++++++---- crates/gpui/src/window.rs | 323 ++++++- crates/language_tools/src/key_context_view.rs | 10 +- crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/settings_content.rs | 6 +- crates/settings_content/src/workspace.rs | 6 + crates/settings_ui/src/page_data.rs | 27 +- crates/ui/src/components/keybinding.rs | 184 +++- crates/vim/src/mode_indicator.rs | 4 +- crates/which_key/Cargo.toml | 7 + .../src/pending_keystrokes_indicator.rs | 896 ++++++++++++++++++ crates/which_key/src/which_key.rs | 145 ++- crates/which_key/src/which_key_modal.rs | 114 ++- crates/workspace/src/workspace_settings.rs | 2 + crates/zed/src/zed.rs | 6 +- crates/zed_actions/src/lib.rs | 4 +- docs/src/key-bindings.md | 19 + docs/src/reference/all-settings.md | 34 +- docs/src/visual-customization.md | 7 +- 21 files changed, 2269 insertions(+), 287 deletions(-) create mode 100644 crates/which_key/src/pending_keystrokes_indicator.rs diff --git a/Cargo.lock b/Cargo.lock index 7b0c4f151f..717553b405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20992,13 +20992,17 @@ dependencies = [ name = "which_key" version = "0.1.0" dependencies = [ + "collections", "command_palette", "gpui", "settings", + "theme", "theme_settings", "ui", "util", + "vim_mode_setting", "workspace", + "zed_actions", ] [[package]] diff --git a/assets/settings/default.json b/assets/settings/default.json index aba8037190..3e94d4f01c 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1927,6 +1927,10 @@ "line_endings_button": false, // Control when to show the active encoding in the status bar. "active_encoding_button": "non_utf8", + // Whether to show an indicator with a countdown while timed multi-stroke + // input is pending. Hovering the indicator pauses the timeout. Its binding + // preview popover is disabled when "which_key.enabled" is true. + "pending_keystrokes_indicator": true, }, // Settings specific to the terminal "terminal": { @@ -2749,6 +2753,8 @@ // Which-key popup settings "which_key": { // Whether to show the which-key popup when holding down key combinations. + // When enabled, the pending keystrokes indicator remains visible, but its + // binding preview popover is disabled. "enabled": false, // Delay in milliseconds before showing the which-key popup. "delay_ms": 1000, diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 5a037eb1a6..f18bd0e92a 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -650,6 +650,248 @@ mod tests { ) } + struct PendingInputTestView { + focus_handle: FocusHandle, + action_count: Rc>, + secondary_action_count: Rc>, + } + + #[derive(Clone)] + struct PendingTextInputTestView { + focus_handle: FocusHandle, + text: Rc>, + action_count: Rc>, + } + + impl PendingTextInputTestView { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + action_count: Rc::default(), + } + } + } + + impl Element for PendingTextInputTestView { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("pending-text-input-test".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + let action_count = self.action_count.clone(); + window.on_action( + std::any::TypeId::of::(), + move |_, phase, _, _| { + if phase == DispatchPhase::Bubble { + action_count.set(action_count.get() + 1); + } + }, + ); + } + } + + impl IntoElement for PendingTextInputTestView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for PendingTextInputTestView { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { + true + } + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for PendingTextInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + struct PendingInputTimeoutPauseOwner; + + impl Render for PendingInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::{InteractiveElement as _, Styled as _}; + let action_count = self.action_count.clone(); + let secondary_action_count = self.secondary_action_count.clone(); + crate::div() + .key_context("Terminal") + .track_focus(&self.focus_handle) + .size_full() + .on_action(move |_: &TestAction, _, _| { + action_count.set(action_count.get() + 1); + }) + .on_action(move |_: &SecondaryTestAction, _, _| { + secondary_action_count.set(secondary_action_count.get() + 1); + }) + } + } + + fn setup_pending_input_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(|cx| cx.bind_keys(bindings)); + + let action_count = Rc::new(Cell::new(0)); + let secondary_action_count = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view(|_, cx| PendingInputTestView { + focus_handle: cx.focus_handle(), + action_count: action_count.clone(), + secondary_action_count: secondary_action_count.clone(), + }); + let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (cx, action_count, secondary_action_count) + } + + fn setup_pending_input_timeout_test( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ) + } + + fn query_prefers_ime_for_printable_keys(cx: &mut VisualTestContext) -> Option { + let mut platform_window = cx.test_window(cx.window_handle()); + let mut input_handler = platform_window.take_input_handler()?; + let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); + platform_window.set_input_handler(input_handler); + Some(prefers_ime) + } + + fn simulate_pending_binding(cx: &mut VisualTestContext) { + cx.simulate_modifiers_change(crate::Modifiers::control()); + cx.simulate_keystrokes("ctrl-b"); + cx.simulate_modifiers_change(crate::Modifiers::default()); + } + #[test] fn test_keybinding_for_action_bounds() { let tree = test_dispatch_tree(vec![KeyBinding::new( @@ -1063,186 +1305,351 @@ mod tests { } #[crate::test] - fn test_input_handler_pending(cx: &mut TestAppContext) { - #[derive(Clone)] - struct CustomElement { - focus_handle: FocusHandle, - text: Rc>, - action_count: Rc>, - } - impl CustomElement { - fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - text: Rc::default(), - action_count: Rc::default(), - } - } - } - impl Element for CustomElement { - type RequestLayoutState = (); - - type PrepaintState = (); - - fn id(&self) -> Option { - Some("custom".into()) - } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { - None - } - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), [], cx), ()) - } - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - window.set_focus_handle(&self.focus_handle, cx); - } - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let mut key_context = KeyContext::default(); - key_context.add("Terminal"); - window.set_key_context(key_context); - window.handle_input(&self.focus_handle, self.clone(), cx); - let action_count = self.action_count.clone(); - window.on_action( - std::any::TypeId::of::(), - move |_, phase, _, _| { - if phase == DispatchPhase::Bubble { - action_count.set(action_count.get() + 1); - } - }, - ); - } - } - impl IntoElement for CustomElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } - } - - impl InputHandler for CustomElement { - fn selected_text_range( - &mut self, - _: bool, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { - None - } - - fn text_for_range( - &mut self, - _: Range, - _: &mut Option>, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn replace_text_in_range( - &mut self, - replacement_range: Option>, - text: &str, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(text) - } - - fn replace_and_mark_text_in_range( - &mut self, - replacement_range: Option>, - new_text: &str, - _: Option>, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(new_text) - } - - fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} - - fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { - true - } - - fn bounds_for_range( - &mut self, - _: Range, - _: &mut Window, - _: &mut App, - ) -> Option> { - None - } - - fn character_index_for_point( - &mut self, - _: Point, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - } - impl Render for CustomElement { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.clone() - } - } - + fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) { cx.update(|cx| { - cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("j k", TestAction, Some("Terminal"))]); }); - let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); window.activate_window(); }); - let query_prefers_ime_for_printable_keys = |cx: &mut VisualTestContext| { - let mut platform_window = cx.test_window(cx.window_handle()); - let mut input_handler = platform_window.take_input_handler()?; - let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); - platform_window.set_input_handler(input_handler); - Some(prefers_ime) - }; + cx.simulate_keystrokes("j"); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_some()); + }); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), ""); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + 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_pending_input_timeout_dispatches_shorter_binding(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.update(|window, _| { + assert_eq!( + window + .pending_input() + .map(|pending_input| pending_input.keystrokes().len()), + Some(1) + ); + assert_eq!( + window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .map(|timeout| timeout.duration()), + Some(crate::PENDING_INPUT_TIMEOUT) + ); + }); + assert_eq!(action_count.get(), 0); + + // Emulate a countdown indicator re-rendering the window while waiting for the timeout. + for _ in 0..10 { + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 10); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_running_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_starts_when_binding_becomes_ambiguous(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_none()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_invalid_continuation_while_timeout_paused_replays_pending_input( + cx: &mut TestAppContext, + ) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("x", SecondaryTestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_pauses_and_resumes(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + let other_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&other_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resumes_when_owner_is_released(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 2); + cx.run_until_parked(); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT / 2); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_clearing_pending_input_invalidates_timeout_pause(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + window.focus(&cx.focus_handle(), cx); + assert!(!window.has_pending_keystrokes()); + }); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); assert_eq!(query_prefers_ime_for_printable_keys(cx), Some(true)); cx.simulate_keystrokes("ctrl-x"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 9f2bfea411..9997ac6d0c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1278,12 +1278,139 @@ pub(crate) enum DrawPhase { Focus, } +pub(crate) const PENDING_INPUT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Pending input for a potential multi-stroke key binding. +pub struct PendingInputStatus<'a> { + keystrokes: &'a [Keystroke], + timeout: Option, +} + +impl<'a> PendingInputStatus<'a> { + /// Returns the keystrokes entered so far. + pub fn keystrokes(&self) -> &'a [Keystroke] { + self.keystrokes + } + + /// Returns the timeout state for flushing this input, if it needs a timeout. + pub fn timeout(&self) -> Option { + self.timeout + } +} + +/// The timeout state for pending input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingInputTimeoutStatus { + duration: Duration, + remaining: Duration, + started_at: Option, + paused: bool, +} + +impl PendingInputTimeoutStatus { + /// Returns the full timeout duration. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Returns the duration remaining before pending input is flushed. + pub fn remaining(&self, cx: &App) -> Duration { + self.started_at + .map(|started_at| { + self.remaining + .saturating_sub(cx.background_executor().now() - started_at) + }) + .unwrap_or(self.remaining) + } + + /// Returns whether the timeout is paused. + pub fn is_paused(&self) -> bool { + self.paused + } +} + +#[derive(Debug)] +struct PendingInputTimeout { + duration: Duration, + remaining: Duration, + state: PendingInputTimeoutState, +} + +#[derive(Debug)] +enum PendingInputTimeoutState { + Running { started_at: Instant, task: Task<()> }, + Paused { pause: PendingInputTimeoutPause }, +} + +#[derive(Debug)] +struct PendingInputTimeoutPause { + owner_id: EntityId, + _release_subscription: Subscription, +} + +impl PendingInputTimeout { + fn is_paused(&self) -> bool { + matches!(&self.state, PendingInputTimeoutState::Paused { .. }) + } + + fn pause(&mut self, pause: PendingInputTimeoutPause, now: Instant) -> bool { + match std::mem::replace(&mut self.state, PendingInputTimeoutState::Paused { pause }) { + PendingInputTimeoutState::Running { started_at, task } => { + self.remaining = self.remaining.saturating_sub(now - started_at); + drop(task); + true + } + previous_state @ PendingInputTimeoutState::Paused { .. } => { + self.state = previous_state; + false + } + } + } + + fn pause_owner_id(&self) -> Option { + match &self.state { + PendingInputTimeoutState::Running { .. } => None, + PendingInputTimeoutState::Paused { pause } => Some(pause.owner_id), + } + } + + fn resume(&mut self, owner_id: EntityId, started_at: Instant, task: Task<()>) -> bool { + match std::mem::replace( + &mut self.state, + PendingInputTimeoutState::Running { started_at, task }, + ) { + PendingInputTimeoutState::Paused { pause } if pause.owner_id == owner_id => true, + previous_state => { + self.state = previous_state; + false + } + } + } + + fn reset_duration(&mut self, duration: Duration) { + self.duration = duration; + self.remaining = duration; + } + + fn status(&self) -> PendingInputTimeoutStatus { + let (started_at, paused) = match &self.state { + PendingInputTimeoutState::Running { started_at, .. } => (Some(*started_at), false), + PendingInputTimeoutState::Paused { .. } => (None, true), + }; + PendingInputTimeoutStatus { + duration: self.duration, + remaining: self.remaining, + started_at, + paused, + } + } +} + #[derive(Default, Debug)] struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, - timer: Option>, - needs_timeout: bool, + timeout: Option, } pub(crate) struct ElementStateBox { @@ -5547,7 +5674,7 @@ impl Window { } if !match_result.pending.is_empty() { - currently_pending.timer.take(); + let previous_timeout = currently_pending.timeout.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; @@ -5561,38 +5688,23 @@ impl Window { accepts }); - currently_pending.needs_timeout |= - match_result.pending_has_binding || text_input_requires_timeout; - - 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(); - })); + let needs_timeout = previous_timeout.is_some() + || match_result.pending_has_binding + || text_input_requires_timeout; + currently_pending.timeout = if needs_timeout { + match previous_timeout { + Some(mut timeout) if timeout.is_paused() => { + timeout.reset_duration(PENDING_INPUT_TIMEOUT); + Some(timeout) + } + previous_timeout => { + drop(previous_timeout); + Some(self.new_pending_input_timeout(PENDING_INPUT_TIMEOUT, cx)) + } + } } else { - currently_pending.timer = None; - } + None + }; self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; @@ -5635,6 +5747,44 @@ impl Window { self.pending_input_changed(cx); } + fn new_pending_input_timeout(&self, duration: Duration, cx: &App) -> PendingInputTimeout { + let (started_at, task) = self.start_pending_input_timeout(duration, cx); + PendingInputTimeout { + duration, + remaining: duration, + state: PendingInputTimeoutState::Running { started_at, task }, + } + } + + fn start_pending_input_timeout(&self, remaining: Duration, cx: &App) -> (Instant, Task<()>) { + let started_at = cx.background_executor().now(); + let task = self.spawn(cx, async move |cx| { + cx.background_executor.timer(remaining).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(); + }); + (started_at, task) + } + fn finish_dispatch_key_event( &mut self, event: &dyn Any, @@ -5725,17 +5875,9 @@ impl Window { } } - /// Pending input that can still complete a binding. Input left over from a previous focus can - /// never complete one. - fn active_pending_input(&self) -> Option<&PendingInput> { - self.pending_input - .as_ref() - .filter(|pending_input| pending_input.focus == self.focus) - } - /// Determine whether a potential multi-stroke key binding is in progress on this window. pub fn has_pending_keystrokes(&self) -> bool { - self.active_pending_input().is_some() + self.pending_input().is_some() } #[cfg(test)] @@ -5749,10 +5891,101 @@ impl Window { } } + /// Returns pending input that can still complete a multi-stroke key binding. Input left over + /// from a previous focus can never complete one. + pub fn pending_input(&self) -> Option> { + self.pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .map(|pending_input| PendingInputStatus { + keystrokes: pending_input.keystrokes.as_slice(), + timeout: pending_input + .timeout + .as_ref() + .map(PendingInputTimeout::status), + }) + } + + /// Pauses or resumes the current pending input timeout on behalf of `owner`. + /// + /// A paused timeout resumes automatically if `owner` is released. Returns whether the timeout + /// state changed. A timeout paused by one owner cannot be resumed by another. + pub fn set_pending_input_timeout_paused( + &mut self, + owner: &Entity, + paused: bool, + cx: &mut App, + ) -> bool { + let owner_id = owner.entity_id(); + if !paused { + return self.resume_pending_input_timeout(owner_id, cx); + } + + let timeout = self + .pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_ref()); + let Some(timeout) = timeout else { + return false; + }; + if timeout.is_paused() { + return false; + } + + let release_subscription = self.observe_release(owner, cx, move |_, window, cx| { + window.resume_pending_input_timeout(owner_id, cx); + }); + let now = cx.background_executor().now(); + let changed = self + .pending_input + .as_mut() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| { + timeout.pause( + PendingInputTimeoutPause { + owner_id, + _release_subscription: release_subscription, + }, + now, + ) + }); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + + fn resume_pending_input_timeout(&mut self, owner_id: EntityId, cx: &mut App) -> bool { + let Some(remaining) = self + .pending_input + .as_ref() + .and_then(|pending_input| pending_input.timeout.as_ref()) + .filter(|timeout| timeout.pause_owner_id() == Some(owner_id)) + .map(|timeout| timeout.remaining) + else { + return false; + }; + + let (started_at, task) = self.start_pending_input_timeout(remaining, cx); + let changed = self + .pending_input + .as_mut() + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| timeout.resume(owner_id, started_at, task)); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. pub fn pending_input_keystrokes(&self) -> Option<&[Keystroke]> { - self.active_pending_input() - .map(|pending_input| pending_input.keystrokes.as_slice()) + self.pending_input() + .map(|pending_input| pending_input.keystrokes()) } fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) { diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index d80840a640..3d0b02ba0f 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -1,7 +1,6 @@ use gpui::{ Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription, Task, - actions, }; use itertools::Itertools; use serde_json::json; @@ -12,14 +11,7 @@ use ui::{ h_flex, px, v_flex, }; use workspace::{Item, SplitDirection, Workspace}; - -actions!( - dev, - [ - /// Opens the key context view for debugging keybindings. - OpenKeyContextView - ] -); +use zed_actions::dev::OpenKeyContextView; pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index a776e23df9..297bb8b1be 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -816,6 +816,7 @@ impl VsCodeSettings { cursor_position_button: None, line_endings_button: None, active_encoding_button: None, + pending_keystrokes_indicator: None, }) } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5e00367889..919f946507 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1439,13 +1439,15 @@ pub struct ReplSettingsContent { /// Settings for configuring the which-key popup behaviour. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct WhichKeySettingsContent { - /// Whether to show the which-key popup when holding down key combinations + /// Whether to show the which-key popup when holding down key combinations. + /// When enabled, the pending keystrokes indicator remains visible, but its binding preview + /// popover is disabled. /// /// Default: false pub enabled: Option, /// Delay in milliseconds before showing the which-key popup. /// - /// Default: 700 + /// Default: 1000 pub delay_ms: Option, } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index cfe76bb4ca..b631533a42 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -561,6 +561,12 @@ pub struct StatusBarSettingsContent { /// /// Default: non_utf8 pub active_encoding_button: Option, + /// Whether to show an indicator with a countdown while timed multi-stroke input is pending. + /// Hovering the indicator pauses the timeout. + /// Its binding preview popover is disabled when the which-key popup is enabled. + /// + /// Default: true + pub pending_keystrokes_indicator: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 6d328ca24c..f8bfd3bc8f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1760,7 +1760,7 @@ fn editor_page() -> SettingsPage { SettingsPageItem::SectionHeader("Which-key Menu"), SettingsPageItem::SettingItem(SettingItem { title: "Show Which-key Menu", - description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.", + description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending. The pending keystrokes indicator remains visible, but its binding preview popover is disabled.", field: Box::new(SettingField { organization_override: None, json_path: Some("which_key.enabled"), @@ -3988,7 +3988,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 11] { + fn status_bar_section() -> [SettingsPageItem; 12] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -4102,6 +4102,29 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Pending Keystrokes Indicator", + description: "Show an indicator with a countdown while a multi-stroke key binding is pending. Its binding preview popover is disabled when the which-key menu is enabled.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("status_bar.pending_keystrokes_indicator"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .pending_keystrokes_indicator + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Terminal Button", description: "Show the terminal button in the status bar.", diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 75479df270..f09da65545 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -46,6 +46,8 @@ impl Clone for Source { pub struct KeyBinding { source: Source, size: Option, + style: KeyBindingStyle, + color: Option, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, /// Determines whether the keybinding is meant for vim mode. @@ -54,6 +56,16 @@ pub struct KeyBinding { disabled: bool, } +/// Controls how a [`KeyBinding`] is presented. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyBindingStyle { + /// Render each key using compact key dimensions and muted colors. + #[default] + Default, + /// Render keys and modifier symbols using the same metrics and colors as a [`Label`]. + Label, +} + struct VimStyle(bool); impl Global for VimStyle {} @@ -115,6 +127,8 @@ impl KeyBinding { focus_handle, }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode: KeyBinding::is_vim_mode(cx), platform_style: PlatformStyle::platform(), disabled: false, @@ -125,6 +139,8 @@ impl KeyBinding { Self { source: Source::Keystrokes { keystrokes }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode, platform_style: PlatformStyle::platform(), disabled: false, @@ -143,6 +159,18 @@ impl KeyBinding { self } + /// Sets how this keybinding is presented. + pub fn style(mut self, style: KeyBindingStyle) -> Self { + self.style = style; + self + } + + /// Sets the color used to render the keybinding. + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + /// Sets whether this keybinding is currently disabled. /// Disabled keybinds will be rendered in a dimmed state. pub fn disabled(mut self, disabled: bool) -> Self { @@ -205,13 +233,17 @@ fn render_key( color: Option, platform_style: PlatformStyle, size: impl Into>, + style: KeyBindingStyle, ) -> AnyElement { let key_icon = icon_for_key(key, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { let key = capitalize(key); - Key::new(&key, color).size(size).into_any_element() + match style { + KeyBindingStyle::Default => Key::new(&key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(&key, color).size(size).into_any_element(), + } } } } @@ -223,7 +255,14 @@ impl RenderOnce for KeyBinding { } let render_keybinding = |keystrokes: &[KeybindingKeystroke]| { - let color = self.disabled.then_some(Color::Disabled); + let color = if self.disabled { + Some(Color::Disabled) + } else { + self.color.or(match self.style { + KeyBindingStyle::Default => None, + KeyBindingStyle::Label => Some(Color::Default), + }) + }; h_flex() .debug_selector(|| { @@ -241,15 +280,16 @@ impl RenderOnce for KeyBinding { .children(keystrokes.iter().map(|keystroke| { h_flex() .flex_none() - .py_0p5() + .when(self.style == KeyBindingStyle::Default, |this| this.py_0p5()) .rounded_xs() - .text_color(cx.theme().colors().text_muted) - .children(render_keybinding_keystroke( + .text_color(color.unwrap_or(Color::Muted).color(cx)) + .children(render_keybinding_keystroke_with_style( keystroke, color, self.size, PlatformStyle::platform(), self.vim_mode, + self.style, )) })) .into_any_element() @@ -278,6 +318,24 @@ pub fn render_keybinding_keystroke( size: impl Into>, platform_style: PlatformStyle, vim_mode: bool, +) -> Vec { + render_keybinding_keystroke_with_style( + keystroke, + color, + size, + platform_style, + vim_mode, + KeyBindingStyle::Default, + ) +} + +fn render_keybinding_keystroke_with_style( + keystroke: &KeybindingKeystroke, + color: Option, + size: impl Into>, + platform_style: PlatformStyle, + vim_mode: bool, + style: KeyBindingStyle, ) -> Vec { let use_text = vim_mode || matches!( @@ -287,28 +345,40 @@ pub fn render_keybinding_keystroke( let size = size.into(); if use_text { - let element = Key::new( - keystroke_text( - keystroke.modifiers(), - keystroke.key(), - platform_style, - vim_mode, - ), - color, - ) - .size(size) - .into_any_element(); + let text = keystroke_text( + keystroke.modifiers(), + keystroke.key(), + platform_style, + vim_mode, + ); + let element = match style { + KeyBindingStyle::Default => Key::new(text, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(text, color).size(size).into_any_element(), + }; vec![element] } else { let mut elements = Vec::new(); - elements.extend(render_modifiers( + let modifiers = render_modifiers_with_style( keystroke.modifiers(), platform_style, color, size, true, + style, + ) + .collect::>(); + if style == KeyBindingStyle::Label && !modifiers.is_empty() { + elements.push(h_flex().children(modifiers).mr_0p5().into_any_element()); + } else { + elements.extend(modifiers); + } + elements.push(render_key( + keystroke.key(), + color, + platform_style, + size, + style, )); - elements.push(render_key(keystroke.key(), color, platform_style, size)); elements } } @@ -343,6 +413,24 @@ pub fn render_modifiers( color: Option, size: Option, trailing_separator: bool, +) -> impl Iterator { + render_modifiers_with_style( + modifiers, + platform_style, + color, + size, + trailing_separator, + KeyBindingStyle::Default, + ) +} + +fn render_modifiers_with_style( + modifiers: &Modifiers, + platform_style: PlatformStyle, + color: Option, + size: Option, + trailing_separator: bool, + style: KeyBindingStyle, ) -> impl Iterator { #[derive(Clone)] enum KeyOrIcon { @@ -358,37 +446,42 @@ pub fn render_modifiers( windows: KeyOrIcon, } + let mac_modifier = |icon, label| match style { + KeyBindingStyle::Default => KeyOrIcon::Icon(icon), + KeyBindingStyle::Label => KeyOrIcon::Key(label), + }; + let table = { use KeyOrIcon::*; [ Modifier { enabled: modifiers.function, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "Fn"), linux: Key("Fn"), windows: Key("Fn"), }, Modifier { enabled: modifiers.control, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "⌃"), linux: Key("Ctrl"), windows: Key("Ctrl"), }, Modifier { enabled: modifiers.alt, - mac: Icon(IconName::Option), + mac: mac_modifier(IconName::Option, "⌥"), linux: Key("Alt"), windows: Key("Alt"), }, Modifier { enabled: modifiers.platform, - mac: Icon(IconName::Command), + mac: mac_modifier(IconName::Command, "⌘"), linux: Key("Super"), windows: Key("Win"), }, Modifier { enabled: modifiers.shift, - mac: Icon(IconName::Shift), + mac: mac_modifier(IconName::Shift, "⇧"), linux: Key("Shift"), windows: Key("Shift"), }, @@ -424,9 +517,15 @@ pub fn render_modifiers( }) .flatten() .map(move |key_or_icon| match key_or_icon { - KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(), + KeyOrIcon::Key(key) => match style { + KeyBindingStyle::Default => Key::new(key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(key, color).size(size).into_any_element(), + }, KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), - KeyOrIcon::Plus => "+".into_any_element(), + KeyOrIcon::Plus => match style { + KeyBindingStyle::Default => "+".into_any_element(), + KeyBindingStyle::Label => LabelKey::new("+", color).size(size).into_any_element(), + }, }) } @@ -476,6 +575,41 @@ impl Key { } } +#[derive(IntoElement)] +struct LabelKey { + key: SharedString, + color: Option, + size: Option, +} + +impl RenderOnce for LabelKey { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let size = self + .size + .map(|size| LabelSize::Custom(size.to_rems(window.rem_size()))) + .unwrap_or_default(); + + Label::new(self.key) + .size(size) + .color(self.color.unwrap_or(Color::Default)) + } +} + +impl LabelKey { + fn new(key: impl Into, color: Option) -> Self { + Self { + key: key.into(), + color, + size: None, + } + } + + fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self + } +} + #[derive(IntoElement)] pub struct KeyIcon { icon: IconName, diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 39195a7467..454e0019fb 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -146,11 +146,12 @@ impl Render for ModeIndicator { (pending.into(), Some(mode)) }; h_flex() + .h(ButtonSize::Default.rems()) .gap_1() .when(!label.is_empty(), |el| { el.child( Label::new(label) - .line_height_style(LineHeightStyle::UiLabel) + .size(LabelSize::Small) .weight(FontWeight::MEDIUM), ) }) @@ -166,7 +167,6 @@ impl Render for ModeIndicator { .child( Label::new(mode) .size(LabelSize::Small) - .line_height_style(LineHeightStyle::UiLabel) .weight(FontWeight::MEDIUM) .when( bg_color != system_transparent diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index c2f926efea..18e2bf1c75 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -19,4 +19,11 @@ settings.workspace = true theme_settings.workspace = true ui.workspace = true util.workspace = true +vim_mode_setting.workspace = true workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +collections.workspace = true +gpui = { workspace = true, features = ["test-support"] } +theme.workspace = true diff --git a/crates/which_key/src/pending_keystrokes_indicator.rs b/crates/which_key/src/pending_keystrokes_indicator.rs new file mode 100644 index 0000000000..d4208f11c3 --- /dev/null +++ b/crates/which_key/src/pending_keystrokes_indicator.rs @@ -0,0 +1,896 @@ +use gpui::{ + Action as _, Anchor, Animation, AnimationExt, App, Context, HoverListenerMode, + KeybindingKeystroke, Render, Subscription, Task, Window, anchored, deferred, +}; +use settings::{Settings, SettingsStore}; +use std::{rc::Rc, time::Duration}; +use ui::{ + ButtonLike, CircularProgress, KeyBinding, KeyBindingStyle, prelude::*, + text_for_keybinding_keystrokes, tooltip_container, +}; +use util::ResultExt; +use vim_mode_setting::{HelixModeSetting, VimModeSetting}; +use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, item::ItemHandle}; + +use crate::{ + bindings_for_pending_input, map_pending_keystrokes, which_key_settings::WhichKeySettings, +}; + +const MAX_TOOLTIP_BINDINGS: usize = 10; +const POPOVER_HIDE_DELAY: Duration = Duration::from_millis(300); + +/// A status bar item shown while timed pending input can complete a multi-stroke key binding. +pub struct PendingKeystrokesIndicator { + render_state: Option>, + pending_input_generation: u64, + popover: PopoverState, + _pending_input_subscription: Subscription, + _settings_subscription: Subscription, +} + +#[derive(Default)] +struct PopoverState { + indicator_pointer_over: bool, + pointer_over: bool, + visible: bool, + hide_task: Option>, +} + +impl PopoverState { + fn is_pointer_over(&self) -> bool { + self.indicator_pointer_over || self.pointer_over + } +} + +struct IndicatorRenderState { + keystrokes: Rc<[KeybindingKeystroke]>, + pending_input_generation: u64, + bindings: Vec<(Rc<[KeybindingKeystroke]>, SharedString)>, + timeout_duration: Duration, + remaining_duration: Duration, + timeout_paused: bool, +} + +impl PendingKeystrokesIndicator { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let pending_input_subscription = + cx.observe_pending_input(window, |this: &mut Self, window, cx| { + if this.refresh_render_state(window, cx) { + cx.notify(); + } + }); + + let mut enabled = Self::enabled(cx); + let mut popover_enabled = Self::popover_enabled(cx); + let settings_subscription = + cx.observe_global_in::(window, move |this, window, cx| { + let new_enabled = Self::enabled(cx); + let new_popover_enabled = Self::popover_enabled(cx); + if new_enabled == enabled && new_popover_enabled == popover_enabled { + return; + } + + enabled = new_enabled; + popover_enabled = new_popover_enabled; + if !new_popover_enabled { + this.popover.pointer_over = false; + this.popover.visible = false; + this.popover.hide_task.take(); + } + if this.refresh_render_state(window, cx) { + cx.notify(); + } + this.update_pointer_over_state(window, cx); + }); + + Self { + render_state: None, + pending_input_generation: 0, + popover: PopoverState::default(), + _pending_input_subscription: pending_input_subscription, + _settings_subscription: settings_subscription, + } + } + + fn enabled(cx: &App) -> bool { + let status_bar_settings = StatusBarSettings::get_global(cx); + status_bar_settings.show + && status_bar_settings.pending_keystrokes_indicator + && !VimModeSetting::is_enabled(cx) + && !HelixModeSetting::is_enabled(cx) + } + + fn popover_enabled(cx: &App) -> bool { + !WhichKeySettings::get_global(cx).enabled + } + + fn refresh_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if !Self::enabled(cx) { + return self.clear_render_state(window, cx); + } + + let Some(pending_input) = window.pending_input() else { + return self.clear_render_state(window, cx); + }; + let Some(timeout) = pending_input.timeout() else { + return self.clear_render_state(window, cx); + }; + let keystrokes = pending_input.keystrokes(); + + let mut bindings = bindings_for_pending_input(window, keystrokes) + .into_iter() + .map(|binding| { + let remaining_text = + text_for_keybinding_keystrokes(&binding.remaining_keystrokes, cx); + ( + remaining_text, + binding.remaining_keystrokes, + binding.action_name, + ) + }) + .collect::>(); + bindings.sort_by(|(text_a, keys_a, action_a), (text_b, keys_b, action_b)| { + keys_a + .len() + .cmp(&keys_b.len()) + .then_with(|| text_a.cmp(text_b)) + .then_with(|| action_a.cmp(action_b)) + }); + bindings.dedup_by(|(text_a, _, action_a), (text_b, _, action_b)| { + text_a == text_b && action_a == action_b + }); + + self.pending_input_generation = self.pending_input_generation.wrapping_add(1); + self.render_state = Some(Rc::new(IndicatorRenderState { + keystrokes: map_pending_keystrokes(keystrokes, cx.keyboard_mapper().as_ref()).into(), + pending_input_generation: self.pending_input_generation, + bindings: bindings + .into_iter() + .map(|(_, keystrokes, action)| (Rc::from(keystrokes), action)) + .collect(), + timeout_duration: timeout.duration(), + remaining_duration: timeout.remaining(cx), + timeout_paused: timeout.is_paused(), + })); + true + } + + fn clear_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.popover = PopoverState::default(); + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + self.render_state.take().is_some() + } + + fn render_state(&self) -> Option<&Rc> { + self.render_state.as_ref() + } + + fn set_indicator_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.indicator_pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn set_popover_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn update_pointer_over_state(&mut self, window: &mut Window, cx: &mut Context) { + if self.render_state.is_none() { + return; + } + + if self.popover.is_pointer_over() { + self.popover.hide_task.take(); + let was_visible = self.popover.visible; + self.popover.visible = Self::popover_enabled(cx); + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + if was_visible != self.popover.visible { + cx.notify(); + } + } else if self.popover.visible && self.popover.hide_task.is_none() { + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + self.popover.hide_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(POPOVER_HIDE_DELAY).await; + this.update_in(cx, |this, window, cx| { + this.popover.hide_task.take(); + if this.popover.is_pointer_over() { + return; + } + + this.popover.visible = false; + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + cx.notify(); + }) + .log_err(); + })); + } else if !self.popover.visible { + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + } + } +} + +impl Render for PendingKeystrokesIndicator { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(render_state) = self.render_state().cloned() else { + return div().hidden().into_any_element(); + }; + let remaining_fraction = if render_state.timeout_duration.is_zero() { + 0.0 + } else { + (render_state.remaining_duration.as_secs_f32() + / render_state.timeout_duration.as_secs_f32()) + .clamp(0.0, 1.0) + }; + + let button = ButtonLike::new("pending-keystrokes-indicator") + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::dev::OpenKeyContextView.boxed_clone(), cx); + }) + .child(if cx.reduce_motion() { + Icon::new(IconName::CountdownTimer) + .size(IconSize::XSmall) + .color(Color::Muted) + .into_any_element() + } else { + let progress = CircularProgress::new( + remaining_fraction, + 1.0, + rems_from_px(13_f32).to_pixels(window.rem_size()), + cx, + ) + .stroke_width(rems_from_px(2_f32).to_pixels(window.rem_size())) + .progress_color(cx.theme().colors().text_muted); + if render_state.timeout_paused || render_state.remaining_duration.is_zero() { + progress.into_any_element() + } else { + progress + .with_animation( + ( + "pending-keystrokes-countdown", + render_state.pending_input_generation, + ), + Animation::new(render_state.remaining_duration).with_max_fps(30.0), + move |progress, delta| { + progress.value(remaining_fraction * (1.0 - delta)) + }, + ) + .into_any_element() + } + }) + .child( + KeyBinding::from_keystrokes(render_state.keystrokes.clone(), false) + .size(rems_from_px(12_f32)) + .style(KeyBindingStyle::Label), + ); + + let popover = self.popover.visible.then(|| { + let popover_render_state = render_state.clone(); + let anchored_popover = deferred( + anchored() + .anchor(Anchor::BottomRight) + .snap_to_window_with_margin(px(8.)) + .child( + div() + .id("pending-keystrokes-popover") + .debug_selector(|| "PENDING_KEYSTROKES_POPOVER".into()) + .pb_2() + .occlude() + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_popover_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .child(tooltip_container(cx, |el, _| { + el.child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .child( + KeyBinding::from_keystrokes( + popover_render_state.keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new("is waiting for more keys") + .color(Color::Muted), + ), + ) + .children( + popover_render_state + .bindings + .iter() + .take(MAX_TOOLTIP_BINDINGS) + .map(|(keystrokes, action)| { + h_flex() + .gap_2() + .child( + KeyBinding::from_keystrokes( + keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new(action.clone()) + .size(LabelSize::Small), + ) + }), + ) + .when( + popover_render_state.bindings.len() + > MAX_TOOLTIP_BINDINGS, + |el| { + el.child( + Label::new(format!( + "…and {} more", + popover_render_state.bindings.len() + - MAX_TOOLTIP_BINDINGS + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), + ) + })), + ), + ) + .with_priority(1); + + div() + .absolute() + .top_0() + .right_0() + .w_0() + .h_0() + .child(anchored_popover) + }); + + div() + .id("pending-keystrokes-indicator-wrapper") + .debug_selector(|| "PENDING_KEYSTROKES_INDICATOR".into()) + .relative() + .child(button) + .when_some(popover, |this, popover| this.child(popover)) + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_indicator_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .into_any_element() + } +} + +impl StatusItemView for PendingKeystrokesIndicator { + fn set_active_pane_item( + &mut self, + _active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = Some(false); + })) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use command_palette::humanize_action_name; + use gpui::{ + Entity, FocusHandle, KeyBinding, Modifiers, TestAppContext, VisualTestContext, actions, + point, + }; + + actions!( + pending_keystrokes_indicator_test, + [ShorterBinding, LongerBinding, LongestBinding] + ); + + fn timed_bindings() -> [KeyBinding; 2] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + fn nested_timed_bindings() -> [KeyBinding; 3] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h j", + LongestBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + struct TestView { + focus_handle: FocusHandle, + indicator: Entity, + open_key_context_view_count: Rc>, + } + + #[derive(Debug, PartialEq)] + struct IndicatorSnapshot { + keystrokes: Vec, + generation: u64, + bindings: Vec<(Vec, String)>, + timeout_paused: bool, + popover_visible: bool, + popover_pointer_over: bool, + } + + fn indicator_snapshot(indicator: &PendingKeystrokesIndicator) -> Option { + indicator + .render_state() + .map(|render_state| IndicatorSnapshot { + keystrokes: render_state + .keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + generation: render_state.pending_input_generation, + bindings: render_state + .bindings + .iter() + .map(|(keystrokes, action)| { + ( + keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + action.to_string(), + ) + }) + .collect(), + timeout_paused: render_state.timeout_paused, + popover_visible: indicator.popover.visible, + popover_pointer_over: indicator.popover.pointer_over, + }) + } + + fn setup_indicator_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> ( + Entity, + Rc>, + &mut VisualTestContext, + ) { + cx.update(|cx| { + settings::init(cx); + WhichKeySettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + cx.bind_keys(bindings); + }); + + let open_key_context_view_count = Rc::new(Cell::new(0)); + let (test_view, cx) = cx.add_window_view({ + let open_key_context_view_count = open_key_context_view_count.clone(); + |window, cx| TestView { + focus_handle: cx.focus_handle(), + indicator: cx.new(|cx| PendingKeystrokesIndicator::new(window, cx)), + open_key_context_view_count, + } + }); + let (focus_handle, indicator) = test_view.read_with(cx, |test_view, _| { + (test_view.focus_handle.clone(), test_view.indicator.clone()) + }); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (indicator, open_key_context_view_count, cx) + } + + fn start_pending_input_and_hover_indicator(cx: &mut VisualTestContext) { + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_over_popover(cx: &mut VisualTestContext) { + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + cx.simulate_mouse_move(popover_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_outside(cx: &mut VisualTestContext) { + let outside = cx.update(|window, _| point(window.viewport_size().width - px(1.), px(1.))); + cx.simulate_mouse_move(outside, None, Modifiers::none()); + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .key_context("PendingKeystrokesIndicatorTest") + .track_focus(&self.focus_handle) + .on_action(|_: &ShorterBinding, _, _| {}) + .on_action(|_: &LongerBinding, _, _| {}) + .on_action(|_: &LongestBinding, _, _| {}) + .on_action( + cx.listener(|this, _: &zed_actions::dev::OpenKeyContextView, _, _| { + this.open_key_context_view_count + .set(this.open_key_context_view_count.get() + 1); + }), + ) + .child( + v_flex() + .size_full() + .justify_end() + .items_end() + .child(self.indicator.clone()), + ) + } + } + + #[gpui::test] + fn test_indicator_tracks_pending_input(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let first_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!(first_render_state.keystrokes, vec!["ctrl-b"]); + assert_eq!( + first_render_state.bindings, + vec![ + ( + vec!["h".to_string()], + humanize_action_name(LongerBinding.name()), + ), + ( + vec!["h".to_string(), "j".to_string()], + humanize_action_name(LongestBinding.name()), + ), + ] + ); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + let second_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input snapshot"); + assert_eq!(second_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(second_render_state.generation > first_render_state.generation); + assert_eq!( + second_render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongestBinding.name()), + )] + ); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + } + + #[gpui::test] + fn test_indicator_does_not_apply_which_key_binding_filter(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [ + KeyBinding::new("g", ShorterBinding, Some("PendingKeystrokesIndicatorTest")), + KeyBinding::new("g j", LongerBinding, Some("PendingKeystrokesIndicatorTest")), + ], + ); + + cx.simulate_keystrokes("g"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_keystrokes = window + .pending_input_keystrokes() + .expect("pending input keystrokes"); + assert!(crate::bindings_for_which_key(window, pending_keystrokes).is_empty()); + }); + + let render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!( + render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongerBinding.name()), + )] + ); + } + + #[gpui::test] + fn test_which_key_disables_popover_but_keeps_indicator_and_hover_pause( + cx: &mut TestAppContext, + ) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{ + "status_bar": {"pending_keystrokes_indicator": true}, + "which_key": {"enabled": true} + }"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + assert!(StatusBarSettings::get_global(cx).pending_keystrokes_indicator); + assert!(WhichKeySettings::get_global(cx).enabled); + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_some())); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + cx.run_until_parked(); + + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + }); + + move_pointer_outside(cx); + cx.run_until_parked(); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + } + + #[gpui::test] + fn test_clicking_indicator_opens_key_context_view(cx: &mut TestAppContext) { + let (_, open_key_context_view_count, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_click(indicator_bounds.center(), Modifiers::none()); + + assert_eq!(open_key_context_view_count.get(), 1); + } + + #[gpui::test] + fn test_hovering_indicator_opens_popover_and_pauses_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let paused_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("paused pending input"); + assert!(paused_render_state.timeout_paused); + assert!(paused_render_state.popover_visible); + } + + #[gpui::test] + fn test_popover_is_positioned_above_indicator(cx: &mut TestAppContext) { + let (_, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + assert!( + popover_bounds.bottom() <= indicator_bounds.top(), + "popover {popover_bounds:?} should render above indicator {indicator_bounds:?}" + ); + assert_eq!(popover_bounds.right(), indicator_bounds.right()); + } + + #[gpui::test] + fn test_pointer_handoff_keeps_popover_open_and_timeout_paused(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + + let handoff_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover handoff"); + assert!(handoff_render_state.timeout_paused); + assert!(handoff_render_state.popover_visible); + assert!(handoff_render_state.popover_pointer_over); + + cx.executor().advance_clock(POPOVER_HIDE_DELAY); + cx.run_until_parked(); + let stationary_popover_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input while pointer remains over popover"); + assert!(stationary_popover_render_state.timeout_paused); + assert!(stationary_popover_render_state.popover_visible); + assert!(stationary_popover_render_state.popover_pointer_over); + } + + #[gpui::test] + fn test_open_popover_tracks_pending_input_lifecycle(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + let initial_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("initial pending input"); + move_pointer_over_popover(cx); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + let updated_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input while popover is open"); + assert_eq!(updated_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(updated_render_state.generation > initial_render_state.generation); + assert!(updated_render_state.timeout_paused); + assert!(updated_render_state.popover_visible); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_some()); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + } + + #[gpui::test] + fn test_popover_hides_and_timeout_resumes_after_delay(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + move_pointer_outside(cx); + + cx.executor() + .advance_clock(POPOVER_HIDE_DELAY - Duration::from_millis(1)); + cx.run_until_parked(); + let grace_period_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover dismissal grace period"); + assert!(grace_period_render_state.timeout_paused); + assert!(grace_period_render_state.popover_visible); + + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + let resumed_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("resumed pending input"); + assert!(!resumed_render_state.timeout_paused); + assert!(!resumed_render_state.popover_visible); + } + + #[gpui::test] + fn test_disabling_indicator_releases_timeout_pause(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{"status_bar":{"pending_keystrokes_indicator":false}}"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + } + + #[gpui::test] + fn test_indicator_ignores_pending_input_without_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + )], + ); + + let notification_count = Rc::new(Cell::new(0)); + let _notification_subscription = cx.update({ + let indicator = indicator.clone(); + let notification_count = notification_count.clone(); + move |_, cx| { + cx.observe(&indicator, move |_, _| { + notification_count.set(notification_count.get() + 1); + }) + } + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert!(pending_input.timeout().is_none()); + }); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert_eq!(notification_count.get(), 0); + } +} diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs index d71bd646e7..493aefcfe2 100644 --- a/crates/which_key/src/which_key.rs +++ b/crates/which_key/src/which_key.rs @@ -1,9 +1,11 @@ //! Which-key support for Zed. +mod pending_keystrokes_indicator; mod which_key_modal; mod which_key_settings; -use gpui::{App, Keystroke}; +use gpui::{App, KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, Window}; +pub use pending_keystrokes_indicator::PendingKeystrokesIndicator; use settings::Settings; use std::{sync::LazyLock, time::Duration}; use util::ResultExt; @@ -11,6 +13,69 @@ use which_key_modal::WhichKeyModal; use which_key_settings::WhichKeySettings; use workspace::Workspace; +pub(crate) struct PendingBinding { + pub(crate) remaining_keystrokes: Vec, + pub(crate) action_name: SharedString, +} + +pub(crate) fn map_pending_keystrokes( + keystrokes: &[Keystroke], + keyboard_mapper: &dyn PlatformKeyboardMapper, +) -> Vec { + keystrokes + .iter() + .cloned() + .map(|keystroke| KeybindingKeystroke::new_with_mapper(keystroke, false, keyboard_mapper)) + .collect() +} + +pub(crate) fn bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |_| true) +} + +pub(crate) fn bindings_for_which_key( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |binding| { + let binding_keystrokes = binding.keystrokes(); + !FILTERED_KEYSTROKES.iter().any(|filtered| { + binding_keystrokes.len() >= filtered.len() + && binding_keystrokes[..filtered.len()] + .iter() + .map(|keystroke| keystroke.inner()) + .eq(filtered.iter()) + }) + }) +} + +fn collect_bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], + mut include_binding: impl FnMut(&gpui::KeyBinding) -> bool, +) -> Vec { + window + .possible_bindings_for_input(pending_keystrokes) + .into_iter() + .filter(|binding| include_binding(binding)) + .filter_map(|binding| { + let remaining_keystrokes = binding.keystrokes().get(pending_keystrokes.len()..)?; + if remaining_keystrokes.is_empty() { + return None; + } + let remaining_keystrokes = remaining_keystrokes.to_vec(); + let action_name = command_palette::humanize_action_name(binding.action().name()).into(); + Some(PendingBinding { + remaining_keystrokes, + action_name, + }) + }) + .collect() +} + pub fn init(cx: &mut App) { WhichKeySettings::register(cx); @@ -92,3 +157,81 @@ pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| }) .collect() }); + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use collections::HashMap; + use gpui::PlatformKeyboardMapper; + + use super::*; + + struct TestKeyboardMapper { + call_count: Cell, + } + + impl PlatformKeyboardMapper for TestKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + assert!(!use_key_equivalents); + self.call_count.set(self.call_count.get() + 1); + + #[cfg(target_os = "windows")] + { + KeybindingKeystroke::new( + keystroke, + gpui::Modifiers::control_shift(), + "2".to_owned(), + ) + } + #[cfg(not(target_os = "windows"))] + { + KeybindingKeystroke::from_keystroke(keystroke) + } + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } + } + + #[gpui::test] + fn test_map_pending_keystrokes_uses_platform_mapper(cx: &mut App) { + let keyboard_mapper = TestKeyboardMapper { + call_count: Cell::new(0), + }; + let keystroke = Keystroke::parse("ctrl-@").expect("valid test keystroke"); + + let mapped = map_pending_keystrokes(std::slice::from_ref(&keystroke), &keyboard_mapper); + + assert_eq!(keyboard_mapper.call_count.get(), 1); + let Some(mapped_keystroke) = mapped.first() else { + panic!("expected mapped pending keystroke"); + }; + assert_eq!(mapped_keystroke.inner(), &keystroke); + #[cfg(target_os = "windows")] + { + assert_eq!( + mapped_keystroke.modifiers(), + &gpui::Modifiers::control_shift() + ); + assert_eq!(mapped_keystroke.key(), "2"); + } + + let expected_display_text = if cfg!(target_os = "windows") { + "Ctrl-Shift-2" + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + "Ctrl-@" + } else { + "Control-@" + }; + assert_eq!( + ui::text_for_keybinding_keystrokes(&mapped, cx), + expected_display_text + ); + } +} diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 0a5bde4c9c..689ce10cc7 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -10,11 +10,11 @@ use std::collections::HashMap; use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, - text_for_keybinding_keystrokes, text_for_keystrokes, + text_for_keybinding_keystrokes, }; use workspace::{ModalView, Workspace}; -use crate::FILTERED_KEYSTROKES; +use crate::{bindings_for_which_key, map_pending_keystrokes}; pub struct WhichKeyModal { _workspace: WeakEntity, @@ -65,27 +65,9 @@ impl WhichKeyModal { cx.emit(DismissEvent); return; }; - let bindings = window.possible_bindings_for_input(pending_keys); - - let mut binding_data = bindings - .iter() - .map(|binding| (binding.keystrokes().to_vec(), binding.action())) - .filter(|(keystrokes, _action)| { - // Check if this binding matches any filtered keystroke pattern - !FILTERED_KEYSTROKES.iter().any(|filtered| { - keystrokes.len() >= filtered.len() - && keystrokes[..filtered.len()] - .iter() - .map(|keystroke| keystroke.inner()) - .eq(filtered.iter()) - }) - }) - .filter_map(|(keystrokes, action)| { - let remaining_keystrokes = keystrokes.get(pending_keys.len()..)?.to_vec(); - let action_name: SharedString = - command_palette::humanize_action_name(action.name()).into(); - Some((remaining_keystrokes, action_name)) - }) + let mut binding_data = bindings_for_which_key(window, pending_keys) + .into_iter() + .map(|binding| (binding.remaining_keystrokes, binding.action_name)) .collect(); binding_data = group_bindings(binding_data); @@ -119,7 +101,8 @@ impl WhichKeyModal { text_a.cmp(&text_b) }); binding_data.dedup(); - self.pending_keys = text_for_keystrokes(&pending_keys, cx).into(); + let pending_keys = map_pending_keystrokes(pending_keys, cx.keyboard_mapper().as_ref()); + self.pending_keys = text_for_keybinding_keystrokes(&pending_keys, cx).into(); self.bindings = binding_data .into_iter() .map(|(keystrokes, action)| { @@ -310,10 +293,57 @@ fn group_bindings( mod tests { #[cfg(target_os = "windows")] use gpui::Modifiers; - use gpui::{InvalidKeystrokeError, Keystroke}; + use gpui::{ + Action as _, Entity, FocusHandle, InvalidKeystrokeError, KeyBinding, Keystroke, + TestAppContext, VisualTestContext, actions, + }; use super::*; + actions!( + which_key_modal_test, + [FirstBinding, SecondBinding, ThirdBinding] + ); + + struct TestView { + focus_handle: FocusHandle, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .key_context("WhichKeyModalTest") + .track_focus(&self.focus_handle) + .on_action(|_: &FirstBinding, _, _| {}) + .on_action(|_: &SecondBinding, _, _| {}) + .on_action(|_: &ThirdBinding, _, _| {}) + } + } + + fn setup_modal_test<'a>( + cx: &'a mut TestAppContext, + bindings: impl IntoIterator, + pending_keystrokes: &str, + ) -> (Entity, &'a mut VisualTestContext) { + cx.update(|cx| cx.bind_keys(bindings)); + let (test_view, cx) = cx.add_window_view(|_, cx| TestView { + focus_handle: cx.focus_handle(), + }); + let focus_handle = test_view.read_with(cx, |test_view, _| test_view.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + cx.simulate_keystrokes(pending_keystrokes); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + + let modal = cx.update(|window, cx| { + cx.new(|cx| WhichKeyModal::new(WeakEntity::new_invalid(), window, cx)) + }); + (modal, cx) + } + #[test] fn test_group_bindings_preserves_keybinding_keystrokes() -> Result<(), InvalidKeystrokeError> { #[cfg(target_os = "windows")] @@ -334,4 +364,38 @@ mod tests { ); Ok(()) } + + #[gpui::test] + fn test_which_key_modal_groups_and_orders_pending_bindings(cx: &mut TestAppContext) { + let (modal, cx) = setup_modal_test( + cx, + [ + KeyBinding::new("ctrl-b h", FirstBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b h j", SecondBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b k", ThirdBinding, Some("WhichKeyModalTest")), + ], + "ctrl-b", + ); + + let h = KeybindingKeystroke::from_keystroke( + Keystroke::parse("h").expect("valid test keystroke"), + ); + let k = KeybindingKeystroke::from_keystroke( + Keystroke::parse("k").expect("valid test keystroke"), + ); + let h_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[h], cx)); + let k_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[k], cx)); + let expected_bindings = vec![ + ( + k_text.into(), + command_palette::humanize_action_name(ThirdBinding.name()).into(), + ), + (h_text.into(), SharedString::from("+2 keybinds")), + ]; + + assert_eq!( + modal.read_with(cx, |modal, _| modal.bindings.clone()), + expected_bindings + ); + } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 2a84de6fb2..edc9e11138 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -217,6 +217,7 @@ pub struct StatusBarSettings { pub cursor_position_button: bool, pub line_endings_button: bool, pub active_encoding_button: EncodingDisplayOptions, + pub pending_keystrokes_indicator: bool, } impl Settings for StatusBarSettings { @@ -229,6 +230,7 @@ impl Settings for StatusBarSettings { cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), active_encoding_button: status_bar.active_encoding_button.unwrap(), + pending_keystrokes_indicator: status_bar.pending_keystrokes_indicator.unwrap(), } } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3a1704b2a6..6dea6ec15a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -610,6 +610,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let active_toolchain_language = cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx)); let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx)); + let pending_keystrokes_indicator = + cx.new(|cx| which_key::PendingKeystrokesIndicator::new(window, cx)); let image_info = cx.new(|_cx| ImageInfo::new(workspace)); let lsp_button_menu_handle = PopoverMenuHandle::default(); @@ -641,9 +643,11 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { status_bar.add_right_item(active_buffer_language, window, cx); status_bar.add_right_item(active_toolchain_language, window, cx); status_bar.add_right_item(line_ending_indicator, window, cx); - status_bar.add_right_item(vim_mode_indicator, window, cx); status_bar.add_right_item(cursor_position, window, cx); status_bar.add_right_item(image_info, window, cx); + // Keep these last so they stay leftmost and can change without moving the other items. + status_bar.add_right_item(vim_mode_indicator, window, cx); + status_bar.add_right_item(pending_keystrokes_indicator, window, cx); }); let panels_task = initialize_panels(window, cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 7f0a14d810..149991a64d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -254,7 +254,9 @@ pub mod dev { ToggleFpsOverlay, /// Resets the debug frame-time overlay's statistics, except for the /// total frame count. - ResetFrameOverlayStats + ResetFrameOverlayStats, + /// Opens the key context view for debugging keybindings. + OpenKeyContextView ] ); } diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 1f02b7048f..1f83a7aacc 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -175,6 +175,25 @@ The other kind of conflict that arises is when you have two bindings, one of whi When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +Zed can also wait before inserting printable text when it might begin a multi-stroke binding. For example, with a `j k` binding, typing `j` waits briefly for `k`; otherwise, `j` is inserted after the timeout. + +Whenever pending input has a timeout, a countdown indicator with the pending keystrokes is shown in the status bar. Hovering it lists the bindings that could still match and pauses the timeout so you can read them. The timeout resumes with the same remaining duration when the pointer leaves. 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. + +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 +popover is disabled. Or add this to your settings.json: + +```json [settings] +{ + "which_key": { + "enabled": true + } +} +``` + +Set `which_key.delay_ms` to change how long Zed waits before opening the menu. + ### Non-QWERTY keyboards Zed's support for non-QWERTY keyboards is still a work in progress. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index f38478d146..d688e3e963 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -1859,13 +1859,25 @@ Positive `integer` value between 1 and 32. Values outside of this range will be ```json [settings] { "status_bar": { + "show_active_file": false, "active_language_button": true, "cursor_position_button": true, - "line_endings_button": false + "line_endings_button": false, + "active_encoding_button": "non_utf8", + "pending_keystrokes_indicator": true } } ``` +**Options** + +- `show_active_file`: Whether to show the name of the active file in the status bar +- `active_language_button`: Whether to show the active language button (clicking it opens the language selector) +- `cursor_position_button`: Whether to show the cursor position button (clicking it opens the go-to-line/column input) +- `line_endings_button`: Whether to show the active line endings button (clicking it opens the line-ending selector) +- `active_encoding_button`: When to show the active encoding button: `"enabled"`, `"disabled"`, or `"non_utf8"` (only for encodings other than UTF-8 without BOM) +- `pending_keystrokes_indicator`: Whether to show an indicator with a countdown while timed multi-stroke input is pending. Hovering the indicator pauses the timeout. Its binding preview popover is disabled when the which-key popup is enabled (see [key bindings](../key-bindings.md#precedence)) + There is an experimental setting that completely hides the status bar. This causes major usability problems (you will be unable to use many of Zed's features), but is provided for those who value screen real-estate above all else. ```json @@ -5293,6 +5305,26 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `vim_mode` - Default: `false` +## Which-key Menu + +- Description: Show matching bindings while a multi-stroke binding is pending. +- Setting: `which_key` +- Default: + +```json [settings] +{ + "which_key": { + "enabled": false, + "delay_ms": 1000 + } +} +``` + +**Options** + +- `enabled`: Whether to show the which-key menu. When enabled, the pending keystrokes indicator remains visible, but its binding preview popover is disabled. +- `delay_ms`: How long Zed waits before showing the menu, in milliseconds. + ## When Closing With No Tabs - Description: Whether the window should be closed when using 'close active item' on a window with no tabs diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index da848c6325..afb301efc4 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -358,7 +358,12 @@ TBD: Centered layout related settings // Show/hide a button that displays the buffer's character encoding. // If set to "non_utf8", the button is hidden only for UTF-8 without BOM. // Defaults to "non_utf8". - "active_encoding_button": "non_utf8" + "active_encoding_button": "non_utf8", + // Show/hide an indicator with a countdown while timed multi-stroke + // input is pending. Hovering it pauses the timeout. Unless the which-key + // menu is enabled, hovering also lists the bindings that could still match. + // Defaults to true. + "pending_keystrokes_indicator": true }, "global_lsp_settings": { // Show/hide the LSP button in the status bar.