Add KeyContextView (#19872)

Conrad Irwin created

Release Notes:

- Added `cmd-shift-p debug: Open Key Context View` to help debug custom
key bindings



https://github.com/user-attachments/assets/de273c97-5b27-45aa-9ff1-f943b0ed7dfe

Change summary

Cargo.lock                                    |   2 
crates/gpui/src/keymap.rs                     |  12 
crates/gpui/src/keymap/binding.rs             |   5 
crates/gpui/src/keymap/context.rs             |  34 ++
crates/gpui/src/platform/keystroke.rs         |  26 +
crates/gpui/src/window.rs                     |  26 +
crates/language_tools/Cargo.toml              |   2 
crates/language_tools/src/key_context_view.rs | 280 +++++++++++++++++++++
crates/language_tools/src/language_tools.rs   |   2 
crates/zed/src/zed.rs                         |   3 
crates/zed/src/zed/app_menus.rs               |   5 
crates/zed_actions/src/lib.rs                 |   1 
12 files changed, 390 insertions(+), 8 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6346,6 +6346,7 @@ dependencies = [
  "env_logger 0.11.5",
  "futures 0.3.30",
  "gpui",
+ "itertools 0.13.0",
  "language",
  "lsp",
  "project",
@@ -6357,6 +6358,7 @@ dependencies = [
  "ui",
  "util",
  "workspace",
+ "zed_actions",
 ]
 
 [[package]]

crates/gpui/src/keymap.rs 🔗

@@ -75,6 +75,18 @@ impl Keymap {
             .filter(move |binding| binding.action().partial_eq(action))
     }
 
+    /// all bindings for input returns all bindings that might match the input
+    /// (without checking context)
+    pub fn all_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+        self.bindings()
+            .rev()
+            .filter_map(|binding| {
+                binding.match_keystrokes(input).filter(|pending| !pending)?;
+                Some(binding.clone())
+            })
+            .collect()
+    }
+
     /// bindings_for_input returns a list of bindings that match the given input,
     /// and a boolean indicating whether or not more bindings might match if
     /// the input was longer.

crates/gpui/src/keymap/binding.rs 🔗

@@ -69,6 +69,11 @@ impl KeyBinding {
     pub fn action(&self) -> &dyn Action {
         self.action.as_ref()
     }
+
+    /// Get the predicate used to match this binding
+    pub fn predicate(&self) -> Option<&KeyBindingContextPredicate> {
+        self.context_predicate.as_ref()
+    }
 }
 
 impl std::fmt::Debug for KeyBinding {

crates/gpui/src/keymap/context.rs 🔗

@@ -11,9 +11,12 @@ use std::fmt;
 pub struct KeyContext(SmallVec<[ContextEntry; 1]>);
 
 #[derive(Clone, Debug, Eq, PartialEq, Hash)]
-struct ContextEntry {
-    key: SharedString,
-    value: Option<SharedString>,
+/// An entry in a KeyContext
+pub struct ContextEntry {
+    /// The key (or name if no value)
+    pub key: SharedString,
+    /// The value
+    pub value: Option<SharedString>,
 }
 
 impl<'a> TryFrom<&'a str> for KeyContext {
@@ -39,6 +42,17 @@ impl KeyContext {
         context
     }
 
+    /// Returns the primary context entry (usually the name of the component)
+    pub fn primary(&self) -> Option<&ContextEntry> {
+        self.0.iter().find(|p| p.value.is_none())
+    }
+
+    /// Returns everything except the primary context entry.
+    pub fn secondary(&self) -> impl Iterator<Item = &ContextEntry> {
+        let primary = self.primary();
+        self.0.iter().filter(move |&p| Some(p) != primary)
+    }
+
     /// Parse a key context from a string.
     /// The key context format is very simple:
     /// - either a single identifier, such as `StatusBar`
@@ -178,6 +192,20 @@ pub enum KeyBindingContextPredicate {
     ),
 }
 
+impl fmt::Display for KeyBindingContextPredicate {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Identifier(name) => write!(f, "{}", name),
+            Self::Equal(left, right) => write!(f, "{} == {}", left, right),
+            Self::NotEqual(left, right) => write!(f, "{} != {}", left, right),
+            Self::Not(pred) => write!(f, "!{}", pred),
+            Self::Child(parent, child) => write!(f, "{} > {}", parent, child),
+            Self::And(left, right) => write!(f, "({} && {})", left, right),
+            Self::Or(left, right) => write!(f, "({} || {})", left, right),
+        }
+    }
+}
+
 impl KeyBindingContextPredicate {
     /// Parse a string in the same format as the keymap's context field.
     ///

crates/gpui/src/platform/keystroke.rs 🔗

@@ -121,6 +121,32 @@ impl Keystroke {
         })
     }
 
+    /// Produces a representation of this key that Parse can understand.
+    pub fn unparse(&self) -> String {
+        let mut str = String::new();
+        if self.modifiers.control {
+            str.push_str("ctrl-");
+        }
+        if self.modifiers.alt {
+            str.push_str("alt-");
+        }
+        if self.modifiers.platform {
+            #[cfg(target_os = "macos")]
+            str.push_str("cmd-");
+
+            #[cfg(target_os = "linux")]
+            str.push_str("super-");
+
+            #[cfg(target_os = "windows")]
+            str.push_str("win-");
+        }
+        if self.modifiers.shift {
+            str.push_str("shift-");
+        }
+        str.push_str(&self.key);
+        str
+    }
+
     /// Returns true if this keystroke left
     /// the ime system in an incomplete state.
     pub fn is_ime_in_progress(&self) -> bool {

crates/gpui/src/window.rs 🔗

@@ -3324,17 +3324,18 @@ impl<'a> WindowContext<'a> {
             return;
         }
 
-        self.pending_input_changed();
         self.propagate_event = true;
         for binding in match_result.bindings {
             self.dispatch_action_on_node(node_id, binding.action.as_ref());
             if !self.propagate_event {
                 self.dispatch_keystroke_observers(event, Some(binding.action));
+                self.pending_input_changed();
                 return;
             }
         }
 
-        self.finish_dispatch_key_event(event, dispatch_path)
+        self.finish_dispatch_key_event(event, dispatch_path);
+        self.pending_input_changed();
     }
 
     fn finish_dispatch_key_event(
@@ -3664,6 +3665,22 @@ impl<'a> WindowContext<'a> {
         receiver
     }
 
+    /// Returns the current context stack.
+    pub fn context_stack(&self) -> Vec<KeyContext> {
+        let dispatch_tree = &self.window.rendered_frame.dispatch_tree;
+        let node_id = self
+            .window
+            .focus
+            .and_then(|focus_id| dispatch_tree.focusable_node_id(focus_id))
+            .unwrap_or_else(|| dispatch_tree.root_node_id());
+
+        dispatch_tree
+            .dispatch_path(node_id)
+            .iter()
+            .filter_map(move |&node_id| dispatch_tree.node(node_id).context.clone())
+            .collect()
+    }
+
     /// Returns all available actions for the focused element.
     pub fn available_actions(&self) -> Vec<Box<dyn Action>> {
         let node_id = self
@@ -3704,6 +3721,11 @@ impl<'a> WindowContext<'a> {
             )
     }
 
+    /// Returns key bindings that invoke the given action on the currently focused element.
+    pub fn all_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+        RefCell::borrow(&self.keymap).all_bindings_for_input(input)
+    }
+
     /// Returns any bindings that would invoke the given action on the given focus handle if it were focused.
     pub fn bindings_for_action_in(
         &self,

crates/language_tools/Cargo.toml 🔗

@@ -19,6 +19,7 @@ copilot.workspace = true
 editor.workspace = true
 futures.workspace = true
 gpui.workspace = true
+itertools.workspace = true
 language.workspace = true
 lsp.workspace = true
 project.workspace = true
@@ -28,6 +29,7 @@ theme.workspace = true
 tree-sitter.workspace = true
 ui.workspace = true
 workspace.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/language_tools/src/key_context_view.rs 🔗

@@ -0,0 +1,280 @@
+use gpui::{
+    actions, Action, AppContext, EventEmitter, FocusHandle, FocusableView,
+    KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription,
+};
+use itertools::Itertools;
+use serde_json::json;
+use ui::{
+    div, h_flex, px, v_flex, ButtonCommon, Clickable, FluentBuilder, InteractiveElement, Label,
+    LabelCommon, LabelSize, ParentElement, SharedString, StatefulInteractiveElement, Styled,
+    ViewContext, VisualContext, WindowContext,
+};
+use ui::{Button, ButtonStyle};
+use workspace::Item;
+use workspace::Workspace;
+
+actions!(debug, [OpenKeyContextView]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(|workspace: &mut Workspace, _| {
+        workspace.register_action(|workspace, _: &OpenKeyContextView, cx| {
+            let key_context_view = cx.new_view(KeyContextView::new);
+            workspace.add_item_to_active_pane(Box::new(key_context_view), None, true, cx)
+        });
+    })
+    .detach();
+}
+
+struct KeyContextView {
+    pending_keystrokes: Option<Vec<Keystroke>>,
+    last_keystrokes: Option<SharedString>,
+    last_possibilities: Vec<(SharedString, SharedString, Option<bool>)>,
+    context_stack: Vec<KeyContext>,
+    focus_handle: FocusHandle,
+    _subscriptions: [Subscription; 2],
+}
+
+impl KeyContextView {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        let sub1 = cx.observe_keystrokes(|this, e, cx| {
+            let mut pending = this.pending_keystrokes.take().unwrap_or_default();
+            pending.push(e.keystroke.clone());
+            let mut possibilities = cx.all_bindings_for_input(&pending);
+            possibilities.reverse();
+            this.context_stack = cx.context_stack();
+            this.last_keystrokes = Some(
+                json!(pending.iter().map(|p| p.unparse()).join(" "))
+                    .to_string()
+                    .into(),
+            );
+            this.last_possibilities = possibilities
+                .into_iter()
+                .map(|binding| {
+                    let match_state = if let Some(predicate) = binding.predicate() {
+                        if this.matches(predicate) {
+                            if this.action_matches(&e.action, binding.action()) {
+                                Some(true)
+                            } else {
+                                Some(false)
+                            }
+                        } else {
+                            None
+                        }
+                    } else {
+                        if this.action_matches(&e.action, binding.action()) {
+                            Some(true)
+                        } else {
+                            Some(false)
+                        }
+                    };
+                    let predicate = if let Some(predicate) = binding.predicate() {
+                        format!("{}", predicate)
+                    } else {
+                        "".to_string()
+                    };
+                    let mut name = binding.action().name();
+                    if name == "zed::NoAction" {
+                        name = "(null)"
+                    }
+
+                    (
+                        name.to_owned().into(),
+                        json!(predicate).to_string().into(),
+                        match_state,
+                    )
+                })
+                .collect();
+        });
+        let sub2 = cx.observe_pending_input(|this, cx| {
+            this.pending_keystrokes = cx
+                .pending_input_keystrokes()
+                .map(|k| k.iter().cloned().collect());
+            if this.pending_keystrokes.is_some() {
+                this.last_keystrokes.take();
+            }
+            cx.notify();
+        });
+
+        Self {
+            context_stack: Vec::new(),
+            pending_keystrokes: None,
+            last_keystrokes: None,
+            last_possibilities: Vec::new(),
+            focus_handle: cx.focus_handle(),
+            _subscriptions: [sub1, sub2],
+        }
+    }
+}
+
+impl EventEmitter<()> for KeyContextView {}
+
+impl FocusableView for KeyContextView {
+    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+impl KeyContextView {
+    fn set_context_stack(&mut self, stack: Vec<KeyContext>, cx: &mut ViewContext<Self>) {
+        self.context_stack = stack;
+        cx.notify()
+    }
+
+    fn matches(&self, predicate: &KeyBindingContextPredicate) -> bool {
+        let mut stack = self.context_stack.clone();
+        while !stack.is_empty() {
+            if predicate.eval(&stack) {
+                return true;
+            }
+            stack.pop();
+        }
+        false
+    }
+
+    fn action_matches(&self, a: &Option<Box<dyn Action>>, b: &dyn Action) -> bool {
+        if let Some(last_action) = a {
+            last_action.partial_eq(b)
+        } else {
+            b.name() == "zed::NoAction"
+        }
+    }
+}
+
+impl Item for KeyContextView {
+    type Event = ();
+
+    fn to_item_events(_: &Self::Event, _: impl FnMut(workspace::item::ItemEvent)) {}
+
+    fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
+        Some("Keyboard Context".into())
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        None
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<workspace::WorkspaceId>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<gpui::View<Self>>
+    where
+        Self: Sized,
+    {
+        Some(cx.new_view(Self::new))
+    }
+}
+
+impl Render for KeyContextView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl ui::IntoElement {
+        use itertools::Itertools;
+        v_flex()
+            .id("key-context-view")
+            .overflow_scroll()
+            .size_full()
+            .max_h_full()
+            .pt_4()
+            .pl_4()
+            .track_focus(&self.focus_handle)
+            .key_context("KeyContextView")
+            .on_mouse_up_out(
+                MouseButton::Left,
+                cx.listener(|this, _, cx| {
+                    this.last_keystrokes.take();
+                    this.set_context_stack(cx.context_stack(), cx);
+                }),
+            )
+            .on_mouse_up_out(
+                MouseButton::Right,
+                cx.listener(|_, _, cx| {
+                    cx.defer(|this, cx| {
+                        this.last_keystrokes.take();
+                        this.set_context_stack(cx.context_stack(), cx);
+                    });
+                }),
+            )
+            .child(Label::new("Keyboard Context").size(LabelSize::Large))
+            .child(Label::new("This view lets you determine the current context stack for creating custom key bindings in Zed. When a keyboard shortcut is triggered, it also shows all the possible contexts it could have triggered in, and which one matched."))
+            .child(
+                h_flex()
+                    .mt_4()
+                    .gap_4()
+                    .child(
+                        Button::new("default", "Open Documentation")
+                            .style(ButtonStyle::Filled)
+                            .on_click(|_, cx| cx.open_url("https://zed.dev/docs/key-bindings")),
+                    )
+                    .child(
+                        Button::new("default", "View default keymap")
+                            .style(ButtonStyle::Filled)
+                            .key_binding(ui::KeyBinding::for_action(
+                                &zed_actions::OpenDefaultKeymap,
+                                cx,
+                            ))
+                            .on_click(|_, cx| {
+                                cx.dispatch_action(workspace::SplitRight.boxed_clone());
+                                cx.dispatch_action(zed_actions::OpenDefaultKeymap.boxed_clone());
+                            }),
+                    )
+                    .child(
+                        Button::new("default", "Edit your keymap")
+                            .style(ButtonStyle::Filled)
+                            .key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymap, cx))
+                            .on_click(|_, cx| {
+                                cx.dispatch_action(workspace::SplitRight.boxed_clone());
+                                cx.dispatch_action(zed_actions::OpenKeymap.boxed_clone());
+                            }),
+                    ),
+            )
+            .child(
+                Label::new("Current Context Stack")
+                    .size(LabelSize::Large)
+                    .mt_8(),
+            )
+            .children({
+                cx.context_stack().iter().enumerate().map(|(i, context)| {
+                    let primary = context.primary().map(|e| e.key.clone()).unwrap_or_default();
+                    let secondary = context
+                        .secondary()
+                        .map(|e| {
+                            if let Some(value) = e.value.as_ref() {
+                                format!("{}={}", e.key, value)
+                            } else {
+                                e.key.to_string()
+                            }
+                        })
+                        .join(" ");
+                    Label::new(format!("{} {}", primary, secondary)).ml(px(12. * (i + 1) as f32))
+                })
+            })
+            .child(Label::new("Last Keystroke").mt_4().size(LabelSize::Large))
+            .when_some(self.pending_keystrokes.as_ref(), |el, keystrokes| {
+                el.child(
+                    Label::new(format!(
+                        "Waiting for more input: {}",
+                        keystrokes.iter().map(|k| k.unparse()).join(" ")
+                    ))
+                    .ml(px(12.)),
+                )
+            })
+            .when_some(self.last_keystrokes.as_ref(), |el, keystrokes| {
+                el.child(Label::new(format!("Typed: {}", keystrokes)).ml_4())
+                    .children(
+                        self.last_possibilities
+                            .iter()
+                            .map(|(name, predicate, state)| {
+                                let (text, color) = match state {
+                                    Some(true) => ("(match)", ui::Color::Success),
+                                    Some(false) => ("(low precedence)", ui::Color::Hint),
+                                    None => ("(no match)", ui::Color::Error),
+                                };
+                                h_flex()
+                                    .gap_2()
+                                    .ml_8()
+                                    .child(div().min_w(px(200.)).child(Label::new(name.clone())))
+                                    .child(Label::new(predicate.clone()))
+                                    .child(Label::new(text).color(color))
+                            }),
+                    )
+            })
+    }
+}

crates/language_tools/src/language_tools.rs 🔗

@@ -1,3 +1,4 @@
+mod key_context_view;
 mod lsp_log;
 mod syntax_tree_view;
 
@@ -12,4 +13,5 @@ pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView};
 pub fn init(cx: &mut AppContext) {
     lsp_log::init(cx);
     syntax_tree_view::init(cx);
+    key_context_view::init(cx);
 }

crates/zed/src/zed.rs 🔗

@@ -68,7 +68,6 @@ actions!(
         Hide,
         HideOthers,
         Minimize,
-        OpenDefaultKeymap,
         OpenDefaultSettings,
         OpenProjectSettings,
         OpenProjectTasks,
@@ -474,7 +473,7 @@ pub fn initialize_workspace(
             .register_action(open_project_tasks_file)
             .register_action(
                 move |workspace: &mut Workspace,
-                      _: &OpenDefaultKeymap,
+                      _: &zed_actions::OpenDefaultKeymap,
                       cx: &mut ViewContext<Workspace>| {
                     open_bundled_file(
                         workspace,

crates/zed/src/zed/app_menus.rs 🔗

@@ -18,7 +18,10 @@ pub fn app_menus() -> Vec<Menu> {
                         MenuItem::action("Open Settings", super::OpenSettings),
                         MenuItem::action("Open Key Bindings", zed_actions::OpenKeymap),
                         MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
-                        MenuItem::action("Open Default Key Bindings", super::OpenDefaultKeymap),
+                        MenuItem::action(
+                            "Open Default Key Bindings",
+                            zed_actions::OpenDefaultKeymap,
+                        ),
                         MenuItem::action("Open Project Settings", super::OpenProjectSettings),
                         MenuItem::action("Select Theme...", theme_selector::Toggle::default()),
                     ],