diff --git a/Cargo.lock b/Cargo.lock index 5b54937bd2..9a30984724 100644 --- a/Cargo.lock +++ b/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]] diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 3eaf6ff3a3..9a0c054a07 100644 --- a/crates/gpui/src/keymap.rs +++ b/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 { + 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. diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index c61210ce25..2fff62c7b6 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/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 { diff --git a/crates/gpui/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs index 2990bff196..fccc02886b 100644 --- a/crates/gpui/src/keymap/context.rs +++ b/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, +/// An entry in a KeyContext +pub struct ContextEntry { + /// The key (or name if no value) + pub key: SharedString, + /// The value + pub value: Option, } 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 { + 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. /// diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 6e0da7dac2..38000f4fb1 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/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 { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2d896f2ee8..e4bea94da0 100644 --- a/crates/gpui/src/window.rs +++ b/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 { + 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> { 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 { + 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, diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index d85f5a6e52..285e128eac 100644 --- a/crates/language_tools/Cargo.toml +++ b/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"] } diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs new file mode 100644 index 0000000000..19f6de2a84 --- /dev/null +++ b/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>, + last_keystrokes: Option, + last_possibilities: Vec<(SharedString, SharedString, Option)>, + context_stack: Vec, + focus_handle: FocusHandle, + _subscriptions: [Subscription; 2], +} + +impl KeyContextView { + pub fn new(cx: &mut ViewContext) -> 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, cx: &mut ViewContext) { + 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>, 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 { + Some("Keyboard Context".into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(Self::new)) + } +} + +impl Render for KeyContextView { + fn render(&mut self, cx: &mut ViewContext) -> 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)) + }), + ) + }) + } +} diff --git a/crates/language_tools/src/language_tools.rs b/crates/language_tools/src/language_tools.rs index 0a1f31f03f..b7a4694cd4 100644 --- a/crates/language_tools/src/language_tools.rs +++ b/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); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a5621cfbd8..bbe24bdaaf 100644 --- a/crates/zed/src/zed.rs +++ b/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| { open_bundled_file( workspace, diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 34c19932dd..5c01724ba7 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -18,7 +18,10 @@ pub fn app_menus() -> Vec { 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()), ], diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index cedacb6d84..7ea5c923c2 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -26,6 +26,7 @@ actions!( zed, [ OpenSettings, + OpenDefaultKeymap, OpenAccountSettings, OpenServerSettings, Quit,