Detailed changes
@@ -19120,6 +19120,20 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "which_key"
+version = "0.1.0"
+dependencies = [
+ "command_palette",
+ "gpui",
+ "serde",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
[[package]]
name = "whoami"
version = "1.6.1"
@@ -20730,6 +20744,7 @@ dependencies = [
"watch",
"web_search",
"web_search_providers",
+ "which_key",
"windows 0.61.3",
"winresource",
"workspace",
@@ -192,6 +192,7 @@ members = [
"crates/vercel",
"crates/vim",
"crates/vim_mode_setting",
+ "crates/which_key",
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
@@ -415,6 +416,7 @@ util_macros = { path = "crates/util_macros" }
vercel = { path = "crates/vercel" }
vim = { path = "crates/vim" }
vim_mode_setting = { path = "crates/vim_mode_setting" }
+which_key = { path = "crates/which_key" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
@@ -2152,6 +2152,13 @@
// The shape can be one of the following: "block", "bar", "underline", "hollow".
"cursor_shape": {},
},
+ // Which-key popup settings
+ "which_key": {
+ // Whether to show the which-key popup when holding down key combinations.
+ "enabled": false,
+ // Delay in milliseconds before showing the which-key popup.
+ "delay_ms": 1000,
+ },
// The server to connect to. If the environment variable
// ZED_SERVER_URL is set, it will override this setting.
"server_url": "https://zed.dev",
@@ -462,6 +462,17 @@ impl DispatchTree {
(bindings, partial, context_stack)
}
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ self.keymap
+ .borrow()
+ .possible_next_bindings_for_input(input, context_stack)
+ }
+
/// dispatch_key processes the keystroke
/// input should be set to the value of `pending` from the previous call to dispatch_key.
/// This returns three instructions to the input handler:
@@ -215,6 +215,41 @@ impl Keymap {
Some(contexts.len())
}
}
+
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ let mut bindings = self
+ .bindings()
+ .enumerate()
+ .rev()
+ .filter_map(|(ix, binding)| {
+ let depth = self.binding_enabled(binding, context_stack)?;
+ let pending = binding.match_keystrokes(input);
+ match pending {
+ None => None,
+ Some(is_pending) => {
+ if !is_pending || is_no_action(&*binding.action) {
+ return None;
+ }
+ Some((depth, BindingIndex(ix), binding))
+ }
+ }
+ })
+ .collect::<Vec<_>>();
+
+ bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+ depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+ });
+
+ bindings
+ .into_iter()
+ .map(|(_, _, binding)| binding.clone())
+ .collect::<Vec<_>>()
+ }
}
#[cfg(test)]
@@ -4450,6 +4450,13 @@ impl Window {
dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
}
+ /// Find the bindings that can follow the current input sequence for the current context stack.
+ pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+ self.rendered_frame
+ .dispatch_tree
+ .possible_next_bindings_for_input(input, &self.context_stack())
+ }
+
fn context_stack_for_focus_handle(
&self,
focus_handle: &FocusHandle,
@@ -158,6 +158,9 @@ pub struct SettingsContent {
/// Default: false
pub disable_ai: Option<SaturatingBool>,
+ /// Settings for the which-key popup.
+ pub which_key: Option<WhichKeySettingsContent>,
+
/// Settings related to Vim mode in Zed.
pub vim: Option<VimSettingsContent>,
}
@@ -976,6 +979,19 @@ pub struct ReplSettingsContent {
pub max_columns: Option<usize>,
}
+/// 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
+ ///
+ /// Default: false
+ pub enabled: Option<bool>,
+ /// Delay in milliseconds before showing the which-key popup.
+ ///
+ /// Default: 700
+ pub delay_ms: Option<u64>,
+}
+
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
/// An ExtendingVec in the settings can only accumulate new values.
///
@@ -215,6 +215,7 @@ impl VsCodeSettings {
vim: None,
vim_mode: None,
workspace: self.workspace_settings_content(),
+ which_key: None,
}
}
@@ -1233,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
}
}).collect(),
}),
+ 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.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.enabled"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.enabled.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .enabled = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Menu Delay",
+ description: "Delay in milliseconds before the which-key menu appears.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.delay_ms"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.delay_ms.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .delay_ms = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Multibuffer"),
SettingsPageItem::SettingItem(SettingItem {
title: "Double Click In Multibuffer",
@@ -0,0 +1,23 @@
+[package]
+name = "which_key"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/which_key.rs"
+doctest = false
+
+[dependencies]
+command_palette.workspace = true
+gpui.workspace = true
+serde.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,98 @@
+//! Which-key support for Zed.
+
+mod which_key_modal;
+mod which_key_settings;
+
+use gpui::{App, Keystroke};
+use settings::Settings;
+use std::{sync::LazyLock, time::Duration};
+use util::ResultExt;
+use which_key_modal::WhichKeyModal;
+use which_key_settings::WhichKeySettings;
+use workspace::Workspace;
+
+pub fn init(cx: &mut App) {
+ WhichKeySettings::register(cx);
+
+ cx.observe_new(|_: &mut Workspace, window, cx| {
+ let Some(window) = window else {
+ return;
+ };
+ let mut timer = None;
+ cx.observe_pending_input(window, move |workspace, window, cx| {
+ if window.pending_input_keystrokes().is_none() {
+ if let Some(modal) = workspace.active_modal::<WhichKeyModal>(cx) {
+ modal.update(cx, |modal, cx| modal.dismiss(cx));
+ };
+ timer.take();
+ return;
+ }
+
+ let which_key_settings = WhichKeySettings::get_global(cx);
+ if !which_key_settings.enabled {
+ return;
+ }
+
+ let delay_ms = which_key_settings.delay_ms;
+
+ timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(delay_ms))
+ .await;
+ workspace_handle
+ .update_in(cx, |workspace, window, cx| {
+ if workspace.active_modal::<WhichKeyModal>(cx).is_some() {
+ return;
+ };
+
+ workspace.toggle_modal(window, cx, |window, cx| {
+ WhichKeyModal::new(workspace_handle.clone(), window, cx)
+ });
+ })
+ .log_err();
+ }));
+ })
+ .detach();
+ })
+ .detach();
+}
+
+// Hard-coded list of keystrokes to filter out from which-key display
+pub static FILTERED_KEYSTROKES: LazyLock<Vec<Vec<Keystroke>>> = LazyLock::new(|| {
+ [
+ // Modifiers on normal vim commands
+ "g h",
+ "g j",
+ "g k",
+ "g l",
+ "g $",
+ "g ^",
+ // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a"
+ "ctrl-w ctrl-a",
+ "ctrl-w ctrl-c",
+ "ctrl-w ctrl-h",
+ "ctrl-w ctrl-j",
+ "ctrl-w ctrl-k",
+ "ctrl-w ctrl-l",
+ "ctrl-w ctrl-n",
+ "ctrl-w ctrl-o",
+ "ctrl-w ctrl-p",
+ "ctrl-w ctrl-q",
+ "ctrl-w ctrl-s",
+ "ctrl-w ctrl-v",
+ "ctrl-w ctrl-w",
+ "ctrl-w ctrl-]",
+ "ctrl-w ctrl-shift-w",
+ "ctrl-w ctrl-g t",
+ "ctrl-w ctrl-g shift-t",
+ ]
+ .iter()
+ .filter_map(|s| {
+ let keystrokes: Result<Vec<_>, _> = s
+ .split(' ')
+ .map(|keystroke_str| Keystroke::parse(keystroke_str))
+ .collect();
+ keystrokes.ok()
+ })
+ .collect()
+});
@@ -0,0 +1,308 @@
+//! Modal implementation for the which-key display.
+
+use gpui::prelude::FluentBuilder;
+use gpui::{
+ App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
+ ScrollHandle, Subscription, WeakEntity, Window,
+};
+use settings::Settings;
+use std::collections::HashMap;
+use theme::ThemeSettings;
+use ui::{
+ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
+ text_for_keystrokes,
+};
+use workspace::{ModalView, Workspace};
+
+use crate::FILTERED_KEYSTROKES;
+
+pub struct WhichKeyModal {
+ _workspace: WeakEntity<Workspace>,
+ focus_handle: FocusHandle,
+ scroll_handle: ScrollHandle,
+ bindings: Vec<(SharedString, SharedString)>,
+ pending_keys: SharedString,
+ _pending_input_subscription: Subscription,
+ _focus_out_subscription: Subscription,
+}
+
+impl WhichKeyModal {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ // Keep focus where it currently is
+ let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
+
+ let handle = cx.weak_entity();
+ let mut this = Self {
+ _workspace: workspace,
+ focus_handle: focus_handle.clone(),
+ scroll_handle: ScrollHandle::new(),
+ bindings: Vec::new(),
+ pending_keys: SharedString::new_static(""),
+ _pending_input_subscription: cx.observe_pending_input(
+ window,
+ |this: &mut Self, window, cx| {
+ this.update_pending_keys(window, cx);
+ },
+ ),
+ _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
+ handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+ }),
+ };
+ this.update_pending_keys(window, cx);
+ this
+ }
+
+ pub fn dismiss(&self, cx: &mut Context<Self>) {
+ cx.emit(DismissEvent)
+ }
+
+ fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(pending_keys) = window.pending_input_keystrokes() else {
+ cx.emit(DismissEvent);
+ return;
+ };
+ let bindings = window.possible_bindings_for_input(pending_keys);
+
+ let mut binding_data = bindings
+ .iter()
+ .map(|binding| {
+ // Map to keystrokes
+ (
+ binding
+ .keystrokes()
+ .iter()
+ .map(|k| k.inner().to_owned())
+ .collect::<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()] == filtered[..]
+ })
+ })
+ .map(|(keystrokes, action)| {
+ // Map to remaining keystrokes and action name
+ let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
+ let action_name: SharedString =
+ command_palette::humanize_action_name(action.name()).into();
+ (remaining_keystrokes, action_name)
+ })
+ .collect();
+
+ binding_data = group_bindings(binding_data);
+
+ // Sort bindings from shortest to longest, with groups last
+ // Using stable sort to preserve relative order of equal elements
+ binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
+ // Groups (actions starting with "+") should go last
+ let is_group_a = action_a.starts_with('+');
+ let is_group_b = action_b.starts_with('+');
+
+ // First, separate groups from non-groups
+ let group_cmp = is_group_a.cmp(&is_group_b);
+ if group_cmp != std::cmp::Ordering::Equal {
+ return group_cmp;
+ }
+
+ // Then sort by keystroke count
+ let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
+ if keystroke_cmp != std::cmp::Ordering::Equal {
+ return keystroke_cmp;
+ }
+
+ // Finally sort by text length, then lexicographically for full stability
+ let text_a = text_for_keystrokes(keystrokes_a, cx);
+ let text_b = text_for_keystrokes(keystrokes_b, cx);
+ let text_len_cmp = text_a.len().cmp(&text_b.len());
+ if text_len_cmp != std::cmp::Ordering::Equal {
+ return text_len_cmp;
+ }
+ text_a.cmp(&text_b)
+ });
+ binding_data.dedup();
+ self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
+ self.bindings = binding_data
+ .into_iter()
+ .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
+ .collect();
+ }
+}
+
+impl Render for WhichKeyModal {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let has_rows = !self.bindings.is_empty();
+ let viewport_size = window.viewport_size();
+
+ let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
+ let max_content_height = px(f32::from(viewport_size.height) * 0.4);
+
+ // Push above status bar when visible
+ let status_height = self
+ ._workspace
+ .upgrade()
+ .and_then(|workspace| {
+ workspace.read_with(cx, |workspace, cx| {
+ if workspace.status_bar_visible(cx) {
+ Some(
+ DynamicSpacing::Base04.px(cx) * 2.0
+ + ThemeSettings::get_global(cx).ui_font_size(cx),
+ )
+ } else {
+ None
+ }
+ })
+ })
+ .unwrap_or(px(0.));
+
+ let margin_bottom = px(16.);
+ let bottom_offset = margin_bottom + status_height;
+
+ // Title section
+ let title_section = {
+ let mut column = v_flex().gap(px(0.)).child(
+ div()
+ .child(
+ Label::new(self.pending_keys.clone())
+ .size(LabelSize::Default)
+ .weight(FontWeight::MEDIUM)
+ .color(Color::Accent),
+ )
+ .mb(px(2.)),
+ );
+
+ if has_rows {
+ column = column.child(
+ div()
+ .child(Divider::horizontal().color(DividerColor::BorderFaded))
+ .mb(px(2.)),
+ );
+ }
+
+ column
+ };
+
+ let content = h_flex()
+ .items_start()
+ .id("which-key-content")
+ .gap(px(8.))
+ .overflow_y_scroll()
+ .track_scroll(&self.scroll_handle)
+ .h_full()
+ .max_h(max_content_height)
+ .child(
+ // Keystrokes column
+ v_flex()
+ .gap(px(4.))
+ .flex_shrink_0()
+ .children(self.bindings.iter().map(|(keystrokes, _)| {
+ div()
+ .child(
+ Label::new(keystrokes.clone())
+ .size(LabelSize::Default)
+ .color(Color::Accent),
+ )
+ .text_align(gpui::TextAlign::Right)
+ })),
+ )
+ .child(
+ // Actions column
+ v_flex()
+ .gap(px(4.))
+ .flex_1()
+ .min_w_0()
+ .children(self.bindings.iter().map(|(_, action_name)| {
+ let is_group = action_name.starts_with('+');
+ let label_color = if is_group {
+ Color::Success
+ } else {
+ Color::Default
+ };
+
+ div().child(
+ Label::new(action_name.clone())
+ .size(LabelSize::Default)
+ .color(label_color)
+ .single_line()
+ .truncate(),
+ )
+ })),
+ );
+
+ div()
+ .id("which-key-buffer-panel-scroll")
+ .occlude()
+ .absolute()
+ .bottom(bottom_offset)
+ .right(px(16.))
+ .min_w(px(220.))
+ .max_w(max_panel_width)
+ .elevation_3(cx)
+ .px(px(12.))
+ .child(v_flex().child(title_section).when(has_rows, |el| {
+ el.child(
+ div()
+ .max_h(max_content_height)
+ .child(content)
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx),
+ )
+ }))
+ }
+}
+
+impl EventEmitter<DismissEvent> for WhichKeyModal {}
+
+impl Focusable for WhichKeyModal {
+ fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl ModalView for WhichKeyModal {
+ fn render_bare(&self) -> bool {
+ true
+ }
+}
+
+fn group_bindings(
+ binding_data: Vec<(Vec<Keystroke>, SharedString)>,
+) -> Vec<(Vec<Keystroke>, SharedString)> {
+ let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
+ HashMap::new();
+
+ // Group bindings by their first keystroke
+ for (remaining_keystrokes, action_name) in binding_data {
+ let first_key = remaining_keystrokes.first().cloned();
+ groups
+ .entry(first_key)
+ .or_default()
+ .push((remaining_keystrokes, action_name));
+ }
+
+ let mut result = Vec::new();
+
+ for (first_key, mut group_bindings) in groups {
+ // Remove duplicates within each group
+ group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
+
+ if let Some(first_key) = first_key
+ && group_bindings.len() > 1
+ {
+ // This is a group - create a single entry with just the first keystroke
+ let first_keystroke = vec![first_key];
+ let count = group_bindings.len();
+ result.push((first_keystroke, format!("+{} keybinds", count).into()));
+ } else {
+ // Not a group or empty keystrokes - add all bindings as-is
+ result.append(&mut group_bindings);
+ }
+ }
+
+ result
+}
@@ -0,0 +1,18 @@
+use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent};
+
+#[derive(Debug, Clone, Copy, RegisterSetting)]
+pub struct WhichKeySettings {
+ pub enabled: bool,
+ pub delay_ms: u64,
+}
+
+impl Settings for WhichKeySettings {
+ fn from_settings(content: &SettingsContent) -> Self {
+ let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap();
+
+ Self {
+ enabled: which_key.enabled.unwrap(),
+ delay_ms: which_key.delay_ms.unwrap(),
+ }
+ }
+}
@@ -22,12 +22,17 @@ pub trait ModalView: ManagedView {
fn fade_out_background(&self) -> bool {
false
}
+
+ fn render_bare(&self) -> bool {
+ false
+ }
}
trait ModalViewHandle {
fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
fn view(&self) -> AnyView;
fn fade_out_background(&self, cx: &mut App) -> bool;
+ fn render_bare(&self, cx: &mut App) -> bool;
}
impl<V: ModalView> ModalViewHandle for Entity<V> {
@@ -42,6 +47,10 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
fn fade_out_background(&self, cx: &mut App) -> bool {
self.read(cx).fade_out_background()
}
+
+ fn render_bare(&self, cx: &mut App) -> bool {
+ self.read(cx).render_bare()
+ }
}
pub struct ActiveModal {
@@ -167,9 +176,13 @@ impl ModalLayer {
impl Render for ModalLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_modal) = &self.active_modal else {
- return div();
+ return div().into_any_element();
};
+ if active_modal.modal.render_bare(cx) {
+ return active_modal.modal.view().into_any_element();
+ }
+
div()
.absolute()
.size_full()
@@ -195,5 +208,6 @@ impl Render for ModalLayer {
}),
),
)
+ .into_any_element()
}
}
@@ -163,6 +163,7 @@ vim_mode_setting.workspace = true
watch.workspace = true
web_search.workspace = true
web_search_providers.workspace = true
+which_key.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_env_vars.workspace = true
@@ -656,6 +656,7 @@ pub fn main() {
inspector_ui::init(app_state.clone(), cx);
json_schema_store::init(cx);
miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx);
+ which_key::init(cx);
cx.observe_global::<SettingsStore>({
let http = app_state.client.http_client();