Sketch in a table for the keybindings UI (#32436)

Mikayla Maki , Ben Kunkle , and Anthony created

Adds the initial semblance of a keymap UI. It is currently gated behind the `settings-ui` feature flag. Follow up PRs will add polish and missing features.

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Anthony <anthony@zed.dev>

Change summary

Cargo.lock                                    |   9 
assets/keymaps/default-linux.json             |   7 
assets/keymaps/default-macos.json             |   7 
crates/command_palette/src/command_palette.rs |  65 
crates/gpui/src/app.rs                        |   5 
crates/gpui/src/elements/div.rs               | 171 ---
crates/gpui/src/elements/img.rs               |   4 
crates/gpui/src/keymap/binding.rs             |  14 
crates/gpui/src/prelude.rs                    |   6 
crates/settings/src/keymap_file.rs            |  77 +
crates/settings/src/settings.rs               |   4 
crates/settings/src/settings_store.rs         |   2 
crates/settings_ui/Cargo.toml                 |   9 
crates/settings_ui/src/keybindings.rs         | 902 +++++++++++++++++++++
crates/settings_ui/src/settings_ui.rs         |   5 
crates/settings_ui/src/ui_components/mod.rs   |   1 
crates/settings_ui/src/ui_components/table.rs | 884 ++++++++++++++++++++
crates/terminal_view/src/terminal_element.rs  |   1 
crates/ui/src/components.rs                   |   2 
crates/ui/src/components/keybinding.rs        |   9 
crates/ui/src/components/table.rs             | 271 ------
crates/workspace/src/theme_preview.rs         |   4 
crates/zed/src/zed.rs                         |   2 
23 files changed, 1,967 insertions(+), 494 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14569,13 +14569,22 @@ dependencies = [
 name = "settings_ui"
 version = "0.1.0"
 dependencies = [
+ "collections",
+ "command_palette",
  "command_palette_hooks",
+ "component",
+ "db",
  "editor",
  "feature_flags",
  "fs",
+ "fuzzy",
  "gpui",
  "log",
+ "menu",
+ "paths",
+ "project",
  "schemars",
+ "search",
  "serde",
  "settings",
  "theme",

assets/keymaps/default-linux.json 🔗

@@ -1067,5 +1067,12 @@
       "ctrl-tab": "pane::ActivateNextItem",
       "ctrl-shift-tab": "pane::ActivatePreviousItem"
     }
+  },
+  {
+    "context": "KeymapEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-f": "search::FocusSearch"
+    }
   }
 ]

assets/keymaps/default-macos.json 🔗

@@ -1167,5 +1167,12 @@
       "ctrl-tab": "pane::ActivateNextItem",
       "ctrl-shift-tab": "pane::ActivatePreviousItem"
     }
+  },
+  {
+    "context": "KeymapEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-f": "search::FocusSearch"
+    }
   }
 ]

crates/command_palette/src/command_palette.rs 🔗

@@ -41,7 +41,7 @@ pub struct CommandPalette {
 /// Removes subsequent whitespace characters and double colons from the query.
 ///
 /// This improves the likelihood of a match by either humanized name or keymap-style name.
-fn normalize_query(input: &str) -> String {
+pub fn normalize_action_query(input: &str) -> String {
     let mut result = String::with_capacity(input.len());
     let mut last_char = None;
 
@@ -297,7 +297,7 @@ impl PickerDelegate for CommandPaletteDelegate {
             let mut commands = self.all_commands.clone();
             let hit_counts = self.hit_counts();
             let executor = cx.background_executor().clone();
-            let query = normalize_query(query.as_str());
+            let query = normalize_action_query(query.as_str());
             async move {
                 commands.sort_by_key(|action| {
                     (
@@ -311,29 +311,17 @@ impl PickerDelegate for CommandPaletteDelegate {
                     .enumerate()
                     .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
                     .collect::<Vec<_>>();
-                let matches = if query.is_empty() {
-                    candidates
-                        .into_iter()
-                        .enumerate()
-                        .map(|(index, candidate)| StringMatch {
-                            candidate_id: index,
-                            string: candidate.string,
-                            positions: Vec::new(),
-                            score: 0.0,
-                        })
-                        .collect()
-                } else {
-                    fuzzy::match_strings(
-                        &candidates,
-                        &query,
-                        true,
-                        true,
-                        10000,
-                        &Default::default(),
-                        executor,
-                    )
-                    .await
-                };
+
+                let matches = fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    true,
+                    true,
+                    10000,
+                    &Default::default(),
+                    executor,
+                )
+                .await;
 
                 tx.send((commands, matches)).await.log_err();
             }
@@ -422,8 +410,8 @@ impl PickerDelegate for CommandPaletteDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let r#match = self.matches.get(ix)?;
-        let command = self.commands.get(r#match.candidate_id)?;
+        let matching_command = self.matches.get(ix)?;
+        let command = self.commands.get(matching_command.candidate_id)?;
         Some(
             ListItem::new(ix)
                 .inset(true)
@@ -436,7 +424,7 @@ impl PickerDelegate for CommandPaletteDelegate {
                         .justify_between()
                         .child(HighlightedLabel::new(
                             command.name.clone(),
-                            r#match.positions.clone(),
+                            matching_command.positions.clone(),
                         ))
                         .children(KeyBinding::for_action_in(
                             &*command.action,
@@ -512,19 +500,28 @@ mod tests {
 
     #[test]
     fn test_normalize_query() {
-        assert_eq!(normalize_query("editor: backspace"), "editor: backspace");
-        assert_eq!(normalize_query("editor:  backspace"), "editor: backspace");
-        assert_eq!(normalize_query("editor:    backspace"), "editor: backspace");
         assert_eq!(
-            normalize_query("editor::GoToDefinition"),
+            normalize_action_query("editor: backspace"),
+            "editor: backspace"
+        );
+        assert_eq!(
+            normalize_action_query("editor:  backspace"),
+            "editor: backspace"
+        );
+        assert_eq!(
+            normalize_action_query("editor:    backspace"),
+            "editor: backspace"
+        );
+        assert_eq!(
+            normalize_action_query("editor::GoToDefinition"),
             "editor:GoToDefinition"
         );
         assert_eq!(
-            normalize_query("editor::::GoToDefinition"),
+            normalize_action_query("editor::::GoToDefinition"),
             "editor:GoToDefinition"
         );
         assert_eq!(
-            normalize_query("editor: :GoToDefinition"),
+            normalize_action_query("editor: :GoToDefinition"),
             "editor: :GoToDefinition"
         );
     }

crates/gpui/src/app.rs 🔗

@@ -1334,6 +1334,11 @@ impl App {
         self.pending_effects.push_back(Effect::RefreshWindows);
     }
 
+    /// Get all key bindings in the app.
+    pub fn key_bindings(&self) -> Rc<RefCell<Keymap>> {
+        self.keymap.clone()
+    }
+
     /// Register a global listener for actions invoked via the keyboard.
     pub fn on_action<A: Action>(&mut self, listener: impl Fn(&A, &mut Self) + 'static) {
         self.global_action_listeners

crates/gpui/src/elements/div.rs 🔗

@@ -613,10 +613,10 @@ pub trait InteractiveElement: Sized {
     /// Track the focus state of the given focus handle on this element.
     /// If the focus handle is focused by the application, this element will
     /// apply its focused styles.
-    fn track_focus(mut self, focus_handle: &FocusHandle) -> FocusableWrapper<Self> {
+    fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
         self.interactivity().focusable = true;
         self.interactivity().tracked_focus_handle = Some(focus_handle.clone());
-        FocusableWrapper { element: self }
+        self
     }
 
     /// Set the keymap context for this element. This will be used to determine
@@ -980,15 +980,35 @@ pub trait InteractiveElement: Sized {
         self.interactivity().block_mouse_except_scroll();
         self
     }
+
+    /// Set the given styles to be applied when this element, specifically, is focused.
+    /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`].
+    fn focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self
+    where
+        Self: Sized,
+    {
+        self.interactivity().focus_style = Some(Box::new(f(StyleRefinement::default())));
+        self
+    }
+
+    /// Set the given styles to be applied when this element is inside another element that is focused.
+    /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`].
+    fn in_focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self
+    where
+        Self: Sized,
+    {
+        self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default())));
+        self
+    }
 }
 
 /// A trait for elements that want to use the standard GPUI interactivity features
 /// that require state.
 pub trait StatefulInteractiveElement: InteractiveElement {
     /// Set this element to focusable.
-    fn focusable(mut self) -> FocusableWrapper<Self> {
+    fn focusable(mut self) -> Self {
         self.interactivity().focusable = true;
-        FocusableWrapper { element: self }
+        self
     }
 
     /// Set the overflow x and y to scroll.
@@ -1118,27 +1138,6 @@ pub trait StatefulInteractiveElement: InteractiveElement {
     }
 }
 
-/// A trait for providing focus related APIs to interactive elements
-pub trait FocusableElement: InteractiveElement {
-    /// Set the given styles to be applied when this element, specifically, is focused.
-    fn focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self
-    where
-        Self: Sized,
-    {
-        self.interactivity().focus_style = Some(Box::new(f(StyleRefinement::default())));
-        self
-    }
-
-    /// Set the given styles to be applied when this element is inside another element that is focused.
-    fn in_focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self
-    where
-        Self: Sized,
-    {
-        self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default())));
-        self
-    }
-}
-
 pub(crate) type MouseDownListener =
     Box<dyn Fn(&MouseDownEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 pub(crate) type MouseUpListener =
@@ -2777,126 +2776,6 @@ impl GroupHitboxes {
     }
 }
 
-/// A wrapper around an element that can be focused.
-pub struct FocusableWrapper<E> {
-    /// The element that is focusable
-    pub element: E,
-}
-
-impl<E: InteractiveElement> FocusableElement for FocusableWrapper<E> {}
-
-impl<E> InteractiveElement for FocusableWrapper<E>
-where
-    E: InteractiveElement,
-{
-    fn interactivity(&mut self) -> &mut Interactivity {
-        self.element.interactivity()
-    }
-}
-
-impl<E: StatefulInteractiveElement> StatefulInteractiveElement for FocusableWrapper<E> {}
-
-impl<E> Styled for FocusableWrapper<E>
-where
-    E: Styled,
-{
-    fn style(&mut self) -> &mut StyleRefinement {
-        self.element.style()
-    }
-}
-
-impl FocusableWrapper<Div> {
-    /// Add a listener to be called when the children of this `Div` are prepainted.
-    /// This allows you to store the [`Bounds`] of the children for later use.
-    pub fn on_children_prepainted(
-        mut self,
-        listener: impl Fn(Vec<Bounds<Pixels>>, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        self.element = self.element.on_children_prepainted(listener);
-        self
-    }
-}
-
-impl<E> Element for FocusableWrapper<E>
-where
-    E: Element,
-{
-    type RequestLayoutState = E::RequestLayoutState;
-    type PrepaintState = E::PrepaintState;
-
-    fn id(&self) -> Option<ElementId> {
-        self.element.id()
-    }
-
-    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
-        self.element.source_location()
-    }
-
-    fn request_layout(
-        &mut self,
-        id: Option<&GlobalElementId>,
-        inspector_id: Option<&InspectorElementId>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> (LayoutId, Self::RequestLayoutState) {
-        self.element.request_layout(id, inspector_id, window, cx)
-    }
-
-    fn prepaint(
-        &mut self,
-        id: Option<&GlobalElementId>,
-        inspector_id: Option<&InspectorElementId>,
-        bounds: Bounds<Pixels>,
-        state: &mut Self::RequestLayoutState,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> E::PrepaintState {
-        self.element
-            .prepaint(id, inspector_id, bounds, state, window, cx)
-    }
-
-    fn paint(
-        &mut self,
-        id: Option<&GlobalElementId>,
-        inspector_id: Option<&InspectorElementId>,
-        bounds: Bounds<Pixels>,
-        request_layout: &mut Self::RequestLayoutState,
-        prepaint: &mut Self::PrepaintState,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        self.element.paint(
-            id,
-            inspector_id,
-            bounds,
-            request_layout,
-            prepaint,
-            window,
-            cx,
-        )
-    }
-}
-
-impl<E> IntoElement for FocusableWrapper<E>
-where
-    E: IntoElement,
-{
-    type Element = E::Element;
-
-    fn into_element(self) -> Self::Element {
-        self.element.into_element()
-    }
-}
-
-impl<E> ParentElement for FocusableWrapper<E>
-where
-    E: ParentElement,
-{
-    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
-        self.element.extend(elements)
-    }
-}
-
 /// A wrapper around an element that can store state, produced after assigning an ElementId.
 pub struct Stateful<E> {
     pub(crate) element: E,
@@ -2927,8 +2806,6 @@ where
     }
 }
 
-impl<E: FocusableElement> FocusableElement for Stateful<E> {}
-
 impl<E> Element for Stateful<E>
 where
     E: Element,

crates/gpui/src/elements/img.rs 🔗

@@ -25,7 +25,7 @@ use std::{
 use thiserror::Error;
 use util::ResultExt;
 
-use super::{FocusableElement, Stateful, StatefulInteractiveElement};
+use super::{Stateful, StatefulInteractiveElement};
 
 /// The delay before showing the loading state.
 pub const LOADING_DELAY: Duration = Duration::from_millis(200);
@@ -509,8 +509,6 @@ impl IntoElement for Img {
     }
 }
 
-impl FocusableElement for Img {}
-
 impl StatefulInteractiveElement for Img {}
 
 impl ImageSource {

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

@@ -2,7 +2,7 @@ use std::rc::Rc;
 
 use collections::HashMap;
 
-use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke};
+use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString};
 use smallvec::SmallVec;
 
 /// A keybinding and its associated metadata, from the keymap.
@@ -11,6 +11,8 @@ pub struct KeyBinding {
     pub(crate) keystrokes: SmallVec<[Keystroke; 2]>,
     pub(crate) context_predicate: Option<Rc<KeyBindingContextPredicate>>,
     pub(crate) meta: Option<KeyBindingMetaIndex>,
+    /// The json input string used when building the keybinding, if any
+    pub(crate) action_input: Option<SharedString>,
 }
 
 impl Clone for KeyBinding {
@@ -20,6 +22,7 @@ impl Clone for KeyBinding {
             keystrokes: self.keystrokes.clone(),
             context_predicate: self.context_predicate.clone(),
             meta: self.meta,
+            action_input: self.action_input.clone(),
         }
     }
 }
@@ -32,7 +35,7 @@ impl KeyBinding {
         } else {
             None
         };
-        Self::load(keystrokes, Box::new(action), context_predicate, None).unwrap()
+        Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap()
     }
 
     /// Load a keybinding from the given raw data.
@@ -41,6 +44,7 @@ impl KeyBinding {
         action: Box<dyn Action>,
         context_predicate: Option<Rc<KeyBindingContextPredicate>>,
         key_equivalents: Option<&HashMap<char, char>>,
+        action_input: Option<SharedString>,
     ) -> std::result::Result<Self, InvalidKeystrokeError> {
         let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes
             .split_whitespace()
@@ -62,6 +66,7 @@ impl KeyBinding {
             action,
             context_predicate,
             meta: None,
+            action_input,
         })
     }
 
@@ -110,6 +115,11 @@ impl KeyBinding {
     pub fn meta(&self) -> Option<KeyBindingMetaIndex> {
         self.meta
     }
+
+    /// Get the action input associated with the action for this binding
+    pub fn action_input(&self) -> Option<SharedString> {
+        self.action_input.clone()
+    }
 }
 
 impl std::fmt::Debug for KeyBinding {

crates/gpui/src/prelude.rs 🔗

@@ -3,7 +3,7 @@
 //! application to avoid having to import each trait individually.
 
 pub use crate::{
-    AppContext as _, BorrowAppContext, Context, Element, FocusableElement, InteractiveElement,
-    IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled,
-    StyledImage, VisualContext, util::FluentBuilder,
+    AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement,
+    ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage,
+    VisualContext, util::FluentBuilder,
 };

crates/settings/src/keymap_file.rs 🔗

@@ -3,7 +3,7 @@ use collections::{BTreeMap, HashMap, IndexMap};
 use fs::Fs;
 use gpui::{
     Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE,
-    KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, NoAction,
+    KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, Keystroke, NoAction, SharedString,
 };
 use schemars::{JsonSchema, json_schema};
 use serde::Deserialize;
@@ -399,7 +399,13 @@ impl KeymapFile {
             },
         };
 
-        let key_binding = match KeyBinding::load(keystrokes, action, context, key_equivalents) {
+        let key_binding = match KeyBinding::load(
+            keystrokes,
+            action,
+            context,
+            key_equivalents,
+            action_input_string.map(SharedString::from),
+        ) {
             Ok(key_binding) => key_binding,
             Err(InvalidKeystrokeError { keystroke }) => {
                 return Err(format!(
@@ -626,6 +632,13 @@ impl KeymapFile {
                     continue;
                 };
                 for (keystrokes, action) in bindings {
+                    let Ok(keystrokes) = keystrokes
+                        .split_whitespace()
+                        .map(Keystroke::parse)
+                        .collect::<Result<Vec<_>, _>>()
+                    else {
+                        continue;
+                    };
                     if keystrokes != target.keystrokes {
                         continue;
                     }
@@ -640,9 +653,9 @@ impl KeymapFile {
             if let Some(index) = found_index {
                 let (replace_range, replace_value) = replace_top_level_array_value_in_json_text(
                     &keymap_contents,
-                    &["bindings", target.keystrokes],
+                    &["bindings", &target.keystrokes_unparsed()],
                     Some(&source_action_value),
-                    Some(source.keystrokes),
+                    Some(&source.keystrokes_unparsed()),
                     index,
                     tab_size,
                 )
@@ -674,7 +687,7 @@ impl KeymapFile {
             value.insert("bindings".to_string(), {
                 let mut bindings = serde_json::Map::new();
                 let action = keybinding.action_value()?;
-                bindings.insert(keybinding.keystrokes.into(), action);
+                bindings.insert(keybinding.keystrokes_unparsed(), action);
                 bindings.into()
             });
 
@@ -701,11 +714,11 @@ pub enum KeybindUpdateOperation<'a> {
 }
 
 pub struct KeybindUpdateTarget<'a> {
-    context: Option<&'a str>,
-    keystrokes: &'a str,
-    action_name: &'a str,
-    use_key_equivalents: bool,
-    input: Option<&'a str>,
+    pub context: Option<&'a str>,
+    pub keystrokes: &'a [Keystroke],
+    pub action_name: &'a str,
+    pub use_key_equivalents: bool,
+    pub input: Option<&'a str>,
 }
 
 impl<'a> KeybindUpdateTarget<'a> {
@@ -721,6 +734,16 @@ impl<'a> KeybindUpdateTarget<'a> {
         };
         return Ok(value);
     }
+
+    fn keystrokes_unparsed(&self) -> String {
+        let mut keystrokes = String::with_capacity(self.keystrokes.len() * 8);
+        for keystroke in self.keystrokes {
+            keystrokes.push_str(&keystroke.unparse());
+            keystrokes.push(' ');
+        }
+        keystrokes.pop();
+        keystrokes
+    }
 }
 
 #[derive(Clone, Copy, PartialEq, Eq)]
@@ -804,6 +827,8 @@ mod tests {
 
     #[test]
     fn keymap_update() {
+        use gpui::Keystroke;
+
         zlog::init_test();
         #[track_caller]
         fn check_keymap_update(
@@ -816,10 +841,18 @@ mod tests {
             pretty_assertions::assert_eq!(expected.to_string(), result);
         }
 
+        #[track_caller]
+        fn parse_keystrokes(keystrokes: &str) -> Vec<Keystroke> {
+            return keystrokes
+                .split(' ')
+                .map(|s| Keystroke::parse(s).expect("Keystrokes valid"))
+                .collect();
+        }
+
         check_keymap_update(
             "[]",
             KeybindUpdateOperation::Add(KeybindUpdateTarget {
-                keystrokes: "ctrl-a",
+                keystrokes: &parse_keystrokes("ctrl-a"),
                 action_name: "zed::SomeAction",
                 context: None,
                 use_key_equivalents: false,
@@ -845,7 +878,7 @@ mod tests {
             ]"#
             .unindent(),
             KeybindUpdateOperation::Add(KeybindUpdateTarget {
-                keystrokes: "ctrl-b",
+                keystrokes: &parse_keystrokes("ctrl-b"),
                 action_name: "zed::SomeOtherAction",
                 context: None,
                 use_key_equivalents: false,
@@ -876,7 +909,7 @@ mod tests {
             ]"#
             .unindent(),
             KeybindUpdateOperation::Add(KeybindUpdateTarget {
-                keystrokes: "ctrl-b",
+                keystrokes: &parse_keystrokes("ctrl-b"),
                 action_name: "zed::SomeOtherAction",
                 context: None,
                 use_key_equivalents: false,
@@ -912,7 +945,7 @@ mod tests {
             ]"#
             .unindent(),
             KeybindUpdateOperation::Add(KeybindUpdateTarget {
-                keystrokes: "ctrl-b",
+                keystrokes: &parse_keystrokes("ctrl-b"),
                 action_name: "zed::SomeOtherAction",
                 context: Some("Zed > Editor && some_condition = true"),
                 use_key_equivalents: true,
@@ -951,14 +984,14 @@ mod tests {
             .unindent(),
             KeybindUpdateOperation::Replace {
                 target: KeybindUpdateTarget {
-                    keystrokes: "ctrl-a",
+                    keystrokes: &parse_keystrokes("ctrl-a"),
                     action_name: "zed::SomeAction",
                     context: None,
                     use_key_equivalents: false,
                     input: None,
                 },
                 source: KeybindUpdateTarget {
-                    keystrokes: "ctrl-b",
+                    keystrokes: &parse_keystrokes("ctrl-b"),
                     action_name: "zed::SomeOtherAction",
                     context: None,
                     use_key_equivalents: false,
@@ -997,14 +1030,14 @@ mod tests {
             .unindent(),
             KeybindUpdateOperation::Replace {
                 target: KeybindUpdateTarget {
-                    keystrokes: "ctrl-a",
+                    keystrokes: &parse_keystrokes("ctrl-a"),
                     action_name: "zed::SomeAction",
                     context: None,
                     use_key_equivalents: false,
                     input: None,
                 },
                 source: KeybindUpdateTarget {
-                    keystrokes: "ctrl-b",
+                    keystrokes: &parse_keystrokes("ctrl-b"),
                     action_name: "zed::SomeOtherAction",
                     context: None,
                     use_key_equivalents: false,
@@ -1038,14 +1071,14 @@ mod tests {
             .unindent(),
             KeybindUpdateOperation::Replace {
                 target: KeybindUpdateTarget {
-                    keystrokes: "ctrl-a",
+                    keystrokes: &parse_keystrokes("ctrl-a"),
                     action_name: "zed::SomeNonexistentAction",
                     context: None,
                     use_key_equivalents: false,
                     input: None,
                 },
                 source: KeybindUpdateTarget {
-                    keystrokes: "ctrl-b",
+                    keystrokes: &parse_keystrokes("ctrl-b"),
                     action_name: "zed::SomeOtherAction",
                     context: None,
                     use_key_equivalents: false,
@@ -1081,14 +1114,14 @@ mod tests {
             .unindent(),
             KeybindUpdateOperation::Replace {
                 target: KeybindUpdateTarget {
-                    keystrokes: "ctrl-a",
+                    keystrokes: &parse_keystrokes("ctrl-a"),
                     action_name: "zed::SomeAction",
                     context: None,
                     use_key_equivalents: false,
                     input: None,
                 },
                 source: KeybindUpdateTarget {
-                    keystrokes: "ctrl-b",
+                    keystrokes: &parse_keystrokes("ctrl-b"),
                     action_name: "zed::SomeOtherAction",
                     context: None,
                     use_key_equivalents: false,

crates/settings/src/settings.rs 🔗

@@ -14,8 +14,8 @@ use util::asset_str;
 pub use editable_setting_control::*;
 pub use key_equivalents::*;
 pub use keymap_file::{
-    KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeymapFile,
-    KeymapFileLoadResult,
+    KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation,
+    KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult,
 };
 pub use settings_file::*;
 pub use settings_json::*;

crates/settings/src/settings_store.rs 🔗

@@ -618,7 +618,7 @@ impl SettingsStore {
         ));
     }
 
-    fn json_tab_size(&self) -> usize {
+    pub fn json_tab_size(&self) -> usize {
         const DEFAULT_JSON_TAB_SIZE: usize = 2;
 
         if let Some((setting_type_id, callback)) = &self.tab_size_callback {

crates/settings_ui/Cargo.toml 🔗

@@ -12,12 +12,21 @@ workspace = true
 path = "src/settings_ui.rs"
 
 [dependencies]
+command_palette.workspace = true
 command_palette_hooks.workspace = true
+component.workspace = true
+collections.workspace = true
+db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
+fuzzy.workspace = true
 gpui.workspace = true
 log.workspace = true
+menu.workspace = true
+paths.workspace = true
+project.workspace = true
+search.workspace = true
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true

crates/settings_ui/src/keybindings.rs 🔗

@@ -0,0 +1,902 @@
+use std::{ops::Range, sync::Arc};
+
+use collections::HashSet;
+use db::anyhow::anyhow;
+use editor::{Editor, EditorEvent};
+use feature_flags::FeatureFlagViewExt;
+use fs::Fs;
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    AppContext as _, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, Subscription,
+    WeakEntity, actions, div,
+};
+use settings::KeybindSource;
+use util::ResultExt;
+
+use ui::{
+    ActiveTheme as _, App, BorrowAppContext, ParentElement as _, Render, SharedString, Styled as _,
+    Window, prelude::*,
+};
+use workspace::{Item, ModalView, SerializableItem, Workspace, register_serializable_item};
+
+use crate::{
+    SettingsUiFeatureFlag,
+    keybindings::persistence::KEYBINDING_EDITORS,
+    ui_components::table::{Table, TableInteractionState},
+};
+
+actions!(zed, [OpenKeymapEditor]);
+
+pub fn init(cx: &mut App) {
+    let keymap_event_channel = KeymapEventChannel::new();
+    cx.set_global(keymap_event_channel);
+
+    cx.on_action(|_: &OpenKeymapEditor, cx| {
+        workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            let existing = workspace
+                .active_pane()
+                .read(cx)
+                .items()
+                .find_map(|item| item.downcast::<KeymapEditor>());
+
+            if let Some(existing) = existing {
+                workspace.activate_item(&existing, true, true, window, cx);
+            } else {
+                let keymap_editor =
+                    cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx));
+                workspace.add_item_to_active_pane(Box::new(keymap_editor), None, true, window, cx);
+            }
+        });
+    });
+
+    cx.observe_new(|_workspace: &mut Workspace, window, cx| {
+        let Some(window) = window else { return };
+
+        let keymap_ui_actions = [std::any::TypeId::of::<OpenKeymapEditor>()];
+
+        command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
+            filter.hide_action_types(&keymap_ui_actions);
+        });
+
+        cx.observe_flag::<SettingsUiFeatureFlag, _>(
+            window,
+            move |is_enabled, _workspace, _, cx| {
+                if is_enabled {
+                    command_palette_hooks::CommandPaletteFilter::update_global(
+                        cx,
+                        |filter, _cx| {
+                            filter.show_action_types(keymap_ui_actions.iter());
+                        },
+                    );
+                } else {
+                    command_palette_hooks::CommandPaletteFilter::update_global(
+                        cx,
+                        |filter, _cx| {
+                            filter.hide_action_types(&keymap_ui_actions);
+                        },
+                    );
+                }
+            },
+        )
+        .detach();
+    })
+    .detach();
+
+    register_serializable_item::<KeymapEditor>(cx);
+}
+
+pub struct KeymapEventChannel {}
+
+impl Global for KeymapEventChannel {}
+
+impl KeymapEventChannel {
+    fn new() -> Self {
+        Self {}
+    }
+
+    pub fn trigger_keymap_changed(cx: &mut App) {
+        let Some(_event_channel) = cx.try_global::<Self>() else {
+            // don't panic if no global defined. This usually happens in tests
+            return;
+        };
+        cx.update_global(|_event_channel: &mut Self, _| {
+            /* triggers observers in KeymapEditors */
+        });
+    }
+}
+
+struct KeymapEditor {
+    workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    _keymap_subscription: Subscription,
+    keybindings: Vec<ProcessedKeybinding>,
+    // corresponds 1 to 1 with keybindings
+    string_match_candidates: Arc<Vec<StringMatchCandidate>>,
+    matches: Vec<StringMatch>,
+    table_interaction_state: Entity<TableInteractionState>,
+    filter_editor: Entity<Editor>,
+    selected_index: Option<usize>,
+}
+
+impl EventEmitter<()> for KeymapEditor {}
+
+impl Focusable for KeymapEditor {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        return self.filter_editor.focus_handle(cx);
+    }
+}
+
+impl KeymapEditor {
+    fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        let _keymap_subscription =
+            cx.observe_global::<KeymapEventChannel>(Self::update_keybindings);
+        let table_interaction_state = TableInteractionState::new(window, cx);
+
+        let filter_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Filter action names...", cx);
+            editor
+        });
+
+        cx.subscribe(&filter_editor, |this, _, e: &EditorEvent, cx| {
+            if !matches!(e, EditorEvent::BufferEdited) {
+                return;
+            }
+
+            this.update_matches(cx);
+        })
+        .detach();
+
+        let mut this = Self {
+            workspace,
+            keybindings: vec![],
+            string_match_candidates: Arc::new(vec![]),
+            matches: vec![],
+            focus_handle: focus_handle.clone(),
+            _keymap_subscription,
+            table_interaction_state,
+            filter_editor,
+            selected_index: None,
+        };
+
+        this.update_keybindings(cx);
+
+        this
+    }
+
+    fn update_matches(&mut self, cx: &mut Context<Self>) {
+        let query = self.filter_editor.read(cx).text(cx);
+        let string_match_candidates = self.string_match_candidates.clone();
+        let executor = cx.background_executor().clone();
+        let keybind_count = self.keybindings.len();
+        let query = command_palette::normalize_action_query(&query);
+        let fuzzy_match = cx.background_spawn(async move {
+            fuzzy::match_strings(
+                &string_match_candidates,
+                &query,
+                true,
+                true,
+                keybind_count,
+                &Default::default(),
+                executor,
+            )
+            .await
+        });
+
+        cx.spawn(async move |this, cx| {
+            let matches = fuzzy_match.await;
+            this.update(cx, |this, cx| {
+                this.selected_index.take();
+                this.scroll_to_item(0, ScrollStrategy::Top, cx);
+                this.matches = matches;
+                cx.notify();
+            })
+        })
+        .detach();
+    }
+
+    fn process_bindings(
+        cx: &mut Context<Self>,
+    ) -> (Vec<ProcessedKeybinding>, Vec<StringMatchCandidate>) {
+        let key_bindings_ptr = cx.key_bindings();
+        let lock = key_bindings_ptr.borrow();
+        let key_bindings = lock.bindings();
+        let mut unmapped_action_names = HashSet::from_iter(cx.all_action_names());
+
+        let mut processed_bindings = Vec::new();
+        let mut string_match_candidates = Vec::new();
+
+        for key_binding in key_bindings {
+            let source = key_binding.meta().map(settings::KeybindSource::from_meta);
+
+            let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx);
+            let ui_key_binding = Some(
+                ui::KeyBinding::new(key_binding.clone(), cx)
+                    .vim_mode(source == Some(settings::KeybindSource::Vim)),
+            );
+
+            let context = key_binding
+                .predicate()
+                .map(|predicate| predicate.to_string())
+                .unwrap_or_else(|| "<global>".to_string());
+
+            let source = source.map(|source| (source, source.name().into()));
+
+            let action_name = key_binding.action().name();
+            unmapped_action_names.remove(&action_name);
+
+            let index = processed_bindings.len();
+            let string_match_candidate = StringMatchCandidate::new(index, &action_name);
+            processed_bindings.push(ProcessedKeybinding {
+                keystroke_text: keystroke_text.into(),
+                ui_key_binding,
+                action: action_name.into(),
+                action_input: key_binding.action_input(),
+                context: context.into(),
+                source,
+            });
+            string_match_candidates.push(string_match_candidate);
+        }
+
+        let empty = SharedString::new_static("");
+        for action_name in unmapped_action_names.into_iter() {
+            let index = processed_bindings.len();
+            let string_match_candidate = StringMatchCandidate::new(index, &action_name);
+            processed_bindings.push(ProcessedKeybinding {
+                keystroke_text: empty.clone(),
+                ui_key_binding: None,
+                action: (*action_name).into(),
+                action_input: None,
+                context: empty.clone(),
+                source: None,
+            });
+            string_match_candidates.push(string_match_candidate);
+        }
+
+        (processed_bindings, string_match_candidates)
+    }
+
+    fn update_keybindings(self: &mut KeymapEditor, cx: &mut Context<KeymapEditor>) {
+        let (key_bindings, string_match_candidates) = Self::process_bindings(cx);
+        self.keybindings = key_bindings;
+        self.string_match_candidates = Arc::new(string_match_candidates);
+        self.matches = self
+            .string_match_candidates
+            .iter()
+            .enumerate()
+            .map(|(ix, candidate)| StringMatch {
+                candidate_id: ix,
+                score: 0.0,
+                positions: vec![],
+                string: candidate.string.clone(),
+            })
+            .collect();
+
+        self.update_matches(cx);
+        cx.notify();
+    }
+
+    fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext {
+        let mut dispatch_context = KeyContext::new_with_defaults();
+        dispatch_context.add("KeymapEditor");
+        dispatch_context.add("menu");
+
+        dispatch_context
+    }
+
+    fn scroll_to_item(&self, index: usize, strategy: ScrollStrategy, cx: &mut App) {
+        let index = usize::min(index, self.matches.len().saturating_sub(1));
+        self.table_interaction_state.update(cx, |this, _cx| {
+            this.scroll_handle.scroll_to_item(index, strategy);
+        });
+    }
+
+    fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(selected) = self.selected_index {
+            let selected = selected + 1;
+            if selected >= self.matches.len() {
+                self.select_last(&Default::default(), window, cx);
+            } else {
+                self.selected_index = Some(selected);
+                self.scroll_to_item(selected, ScrollStrategy::Center, cx);
+                cx.notify();
+            }
+        } else {
+            self.select_first(&Default::default(), window, cx);
+        }
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(selected) = self.selected_index {
+            if selected == 0 {
+                return;
+            }
+
+            let selected = selected - 1;
+
+            if selected >= self.matches.len() {
+                self.select_last(&Default::default(), window, cx);
+            } else {
+                self.selected_index = Some(selected);
+                self.scroll_to_item(selected, ScrollStrategy::Center, cx);
+                cx.notify();
+            }
+        } else {
+            self.select_last(&Default::default(), window, cx);
+        }
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.matches.get(0).is_some() {
+            self.selected_index = Some(0);
+            self.scroll_to_item(0, ScrollStrategy::Center, cx);
+            cx.notify();
+        }
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.matches.last().is_some() {
+            let index = self.matches.len() - 1;
+            self.selected_index = Some(index);
+            self.scroll_to_item(index, ScrollStrategy::Center, cx);
+            cx.notify();
+        }
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(index) = self.selected_index else {
+            return;
+        };
+        let keybind = self.keybindings[self.matches[index].candidate_id].clone();
+
+        self.edit_keybinding(keybind, window, cx);
+    }
+
+    fn edit_keybinding(
+        &mut self,
+        keybind: ProcessedKeybinding,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.workspace
+            .update(cx, |workspace, cx| {
+                let fs = workspace.app_state().fs.clone();
+                workspace.toggle_modal(window, cx, |window, cx| {
+                    let modal = KeybindingEditorModal::new(keybind, fs, window, cx);
+                    window.focus(&modal.focus_handle(cx));
+                    modal
+                });
+            })
+            .log_err();
+    }
+
+    fn focus_search(
+        &mut self,
+        _: &search::FocusSearch,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !self
+            .filter_editor
+            .focus_handle(cx)
+            .contains_focused(window, cx)
+        {
+            window.focus(&self.filter_editor.focus_handle(cx));
+        } else {
+            self.filter_editor.update(cx, |editor, cx| {
+                editor.select_all(&Default::default(), window, cx);
+            });
+        }
+        self.selected_index.take();
+    }
+}
+
+#[derive(Clone)]
+struct ProcessedKeybinding {
+    keystroke_text: SharedString,
+    ui_key_binding: Option<ui::KeyBinding>,
+    action: SharedString,
+    action_input: Option<SharedString>,
+    context: SharedString,
+    source: Option<(KeybindSource, SharedString)>,
+}
+
+impl Item for KeymapEditor {
+    type Event = ();
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
+        "Keymap Editor".into()
+    }
+}
+
+impl Render for KeymapEditor {
+    fn render(&mut self, window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
+        let row_count = self.matches.len();
+        let theme = cx.theme();
+
+        div()
+            .key_context(self.dispatch_context(window, cx))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::focus_search))
+            .on_action(cx.listener(Self::confirm))
+            .size_full()
+            .bg(theme.colors().editor_background)
+            .id("keymap-editor")
+            .track_focus(&self.focus_handle)
+            .px_4()
+            .v_flex()
+            .pb_4()
+            .child(
+                h_flex()
+                    .key_context({
+                        let mut context = KeyContext::new_with_defaults();
+                        context.add("BufferSearchBar");
+                        context
+                    })
+                    .w_full()
+                    .h_12()
+                    .px_4()
+                    .my_4()
+                    .border_2()
+                    .border_color(theme.colors().border)
+                    .child(self.filter_editor.clone()),
+            )
+            .child(
+                Table::new()
+                    .interactable(&self.table_interaction_state)
+                    .striped()
+                    .column_widths([rems(24.), rems(16.), rems(32.), rems(8.)])
+                    .header(["Command", "Keystrokes", "Context", "Source"])
+                    .selected_item_index(self.selected_index)
+                    .on_click_row(cx.processor(|this, row_index, _window, _cx| {
+                        this.selected_index = Some(row_index);
+                    }))
+                    .uniform_list(
+                        "keymap-editor-table",
+                        row_count,
+                        cx.processor(move |this, range: Range<usize>, _window, _cx| {
+                            range
+                                .filter_map(|index| {
+                                    let candidate_id = this.matches.get(index)?.candidate_id;
+                                    let binding = &this.keybindings[candidate_id];
+                                    let action = h_flex()
+                                        .items_start()
+                                        .gap_1()
+                                        .child(binding.action.clone())
+                                        .when_some(
+                                            binding.action_input.clone(),
+                                            |this, binding_input| this.child(binding_input),
+                                        );
+                                    let keystrokes = binding.ui_key_binding.clone().map_or(
+                                        binding.keystroke_text.clone().into_any_element(),
+                                        IntoElement::into_any_element,
+                                    );
+                                    let context = binding.context.clone();
+                                    let source = binding
+                                        .source
+                                        .clone()
+                                        .map(|(_source, name)| name)
+                                        .unwrap_or_default();
+                                    Some([
+                                        action.into_any_element(),
+                                        keystrokes,
+                                        context.into_any_element(),
+                                        source.into_any_element(),
+                                    ])
+                                })
+                                .collect()
+                        }),
+                    ),
+            )
+    }
+}
+
+struct KeybindingEditorModal {
+    editing_keybind: ProcessedKeybinding,
+    keybind_editor: Entity<KeybindInput>,
+    fs: Arc<dyn Fs>,
+    error: Option<String>,
+}
+
+impl ModalView for KeybindingEditorModal {}
+
+impl EventEmitter<DismissEvent> for KeybindingEditorModal {}
+
+impl Focusable for KeybindingEditorModal {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.keybind_editor.focus_handle(cx)
+    }
+}
+
+impl KeybindingEditorModal {
+    pub fn new(
+        editing_keybind: ProcessedKeybinding,
+        fs: Arc<dyn Fs>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Self {
+        let keybind_editor = cx.new(KeybindInput::new);
+        Self {
+            editing_keybind,
+            fs,
+            keybind_editor,
+            error: None,
+        }
+    }
+}
+
+impl Render for KeybindingEditorModal {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = cx.theme().colors();
+        return v_flex()
+            .gap_4()
+            .w(rems(36.))
+            .child(
+                v_flex()
+                    .items_center()
+                    .text_center()
+                    .bg(theme.background)
+                    .border_color(theme.border)
+                    .border_2()
+                    .px_4()
+                    .py_2()
+                    .w_full()
+                    .child(
+                        div()
+                            .text_lg()
+                            .font_weight(FontWeight::BOLD)
+                            .child("Input desired keybinding, then hit save"),
+                    )
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .child(self.keybind_editor.clone())
+                            .child(
+                                IconButton::new("backspace-btn", ui::IconName::Backspace).on_click(
+                                    cx.listener(|this, _event, _window, cx| {
+                                        this.keybind_editor.update(cx, |editor, cx| {
+                                            editor.keystrokes.pop();
+                                            cx.notify();
+                                        })
+                                    }),
+                                ),
+                            )
+                            .child(IconButton::new("clear-btn", ui::IconName::Eraser).on_click(
+                                cx.listener(|this, _event, _window, cx| {
+                                    this.keybind_editor.update(cx, |editor, cx| {
+                                        editor.keystrokes.clear();
+                                        cx.notify();
+                                    })
+                                }),
+                            )),
+                    )
+                    .child(
+                        h_flex().w_full().items_center().justify_center().child(
+                            Button::new("save-btn", "Save")
+                                .label_size(LabelSize::Large)
+                                .on_click(cx.listener(|this, _event, _window, cx| {
+                                    let existing_keybind = this.editing_keybind.clone();
+                                    let fs = this.fs.clone();
+                                    let new_keystrokes = this
+                                        .keybind_editor
+                                        .read_with(cx, |editor, _| editor.keystrokes.clone());
+                                    if new_keystrokes.is_empty() {
+                                        this.error = Some("Keystrokes cannot be empty".to_string());
+                                        cx.notify();
+                                        return;
+                                    }
+                                    let tab_size =
+                                        cx.global::<settings::SettingsStore>().json_tab_size();
+                                    cx.spawn(async move |this, cx| {
+                                        if let Err(err) = save_keybinding_update(
+                                            existing_keybind,
+                                            &new_keystrokes,
+                                            &fs,
+                                            tab_size,
+                                        )
+                                        .await
+                                        {
+                                            this.update(cx, |this, cx| {
+                                                this.error = Some(err);
+                                                cx.notify();
+                                            })
+                                            .log_err();
+                                        }
+                                    })
+                                    .detach();
+                                })),
+                        ),
+                    ),
+            )
+            .when_some(self.error.clone(), |this, error| {
+                this.child(
+                    div()
+                        .bg(theme.background)
+                        .border_color(theme.border)
+                        .border_2()
+                        .rounded_md()
+                        .child(error),
+                )
+            });
+    }
+}
+
+async fn save_keybinding_update(
+    existing: ProcessedKeybinding,
+    new_keystrokes: &[Keystroke],
+    fs: &Arc<dyn Fs>,
+    tab_size: usize,
+) -> Result<(), String> {
+    let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
+        .await
+        .map_err(|err| format!("Failed to load keymap file: {}", err))?;
+    let existing_keystrokes = existing
+        .ui_key_binding
+        .as_ref()
+        .map(|keybinding| keybinding.key_binding.keystrokes())
+        .unwrap_or_default();
+    let operation = if existing.ui_key_binding.is_some() {
+        settings::KeybindUpdateOperation::Replace {
+            target: settings::KeybindUpdateTarget {
+                context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()),
+                keystrokes: existing_keystrokes,
+                action_name: &existing.action,
+                use_key_equivalents: false,
+                input: existing.action_input.as_ref().map(|input| input.as_ref()),
+            },
+            target_source: existing
+                .source
+                .map(|(source, _name)| source)
+                .unwrap_or(KeybindSource::User),
+            source: settings::KeybindUpdateTarget {
+                context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()),
+                keystrokes: new_keystrokes,
+                action_name: &existing.action,
+                use_key_equivalents: false,
+                input: existing.action_input.as_ref().map(|input| input.as_ref()),
+            },
+        }
+    } else {
+        return Err(
+            "Not Implemented: Creating new bindings from unbound actions is not supported yet."
+                .to_string(),
+        );
+    };
+    let updated_keymap_contents =
+        settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
+            .map_err(|err| format!("Failed to update keybinding: {}", err))?;
+    fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents)
+        .await
+        .map_err(|err| format!("Failed to write keymap file: {}", err))?;
+    Ok(())
+}
+
+struct KeybindInput {
+    keystrokes: Vec<Keystroke>,
+    focus_handle: FocusHandle,
+}
+
+impl KeybindInput {
+    fn new(cx: &mut Context<Self>) -> Self {
+        let focus_handle = cx.focus_handle();
+        Self {
+            keystrokes: Vec::new(),
+            focus_handle,
+        }
+    }
+
+    fn on_modifiers_changed(
+        &mut self,
+        event: &ModifiersChangedEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(last) = self.keystrokes.last_mut()
+            && last.key.is_empty()
+        {
+            if !event.modifiers.modified() {
+                self.keystrokes.pop();
+            } else {
+                last.modifiers = event.modifiers;
+            }
+        } else {
+            self.keystrokes.push(Keystroke {
+                modifiers: event.modifiers,
+                key: "".to_string(),
+                key_char: None,
+            });
+        }
+        cx.stop_propagation();
+        cx.notify();
+    }
+
+    fn on_key_down(
+        &mut self,
+        event: &gpui::KeyDownEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if event.is_held {
+            return;
+        }
+        if let Some(last) = self.keystrokes.last_mut()
+            && last.key.is_empty()
+        {
+            *last = event.keystroke.clone();
+        } else {
+            self.keystrokes.push(event.keystroke.clone());
+        }
+        cx.stop_propagation();
+        cx.notify();
+    }
+
+    fn on_key_up(
+        &mut self,
+        event: &gpui::KeyUpEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(last) = self.keystrokes.last_mut()
+            && !last.key.is_empty()
+            && last.modifiers == event.keystroke.modifiers
+        {
+            self.keystrokes.push(Keystroke {
+                modifiers: event.keystroke.modifiers,
+                key: "".to_string(),
+                key_char: None,
+            });
+        }
+        cx.stop_propagation();
+        cx.notify();
+    }
+}
+
+impl Focusable for KeybindInput {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for KeybindInput {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let colors = cx.theme().colors();
+        return div()
+            .track_focus(&self.focus_handle)
+            .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
+            .on_key_down(cx.listener(Self::on_key_down))
+            .on_key_up(cx.listener(Self::on_key_up))
+            .focus(|mut style| {
+                style.border_color = Some(colors.border_focused);
+                style
+            })
+            .h_12()
+            .w_full()
+            .bg(colors.editor_background)
+            .border_2()
+            .border_color(colors.border)
+            .p_4()
+            .flex_row()
+            .text_center()
+            .justify_center()
+            .child(ui::text_for_keystrokes(&self.keystrokes, cx));
+    }
+}
+
+impl SerializableItem for KeymapEditor {
+    fn serialized_item_kind() -> &'static str {
+        "KeymapEditor"
+    }
+
+    fn cleanup(
+        workspace_id: workspace::WorkspaceId,
+        alive_items: Vec<workspace::ItemId>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> gpui::Task<gpui::Result<()>> {
+        workspace::delete_unloaded_items(
+            alive_items,
+            workspace_id,
+            "keybinding_editors",
+            &KEYBINDING_EDITORS,
+            cx,
+        )
+    }
+
+    fn deserialize(
+        _project: Entity<project::Project>,
+        workspace: WeakEntity<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> gpui::Task<gpui::Result<Entity<Self>>> {
+        window.spawn(cx, async move |cx| {
+            if KEYBINDING_EDITORS
+                .get_keybinding_editor(item_id, workspace_id)?
+                .is_some()
+            {
+                cx.update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace, window, cx)))
+            } else {
+                Err(anyhow!("No keybinding editor to deserialize"))
+            }
+        })
+    }
+
+    fn serialize(
+        &mut self,
+        workspace: &mut Workspace,
+        item_id: workspace::ItemId,
+        _closing: bool,
+        _window: &mut Window,
+        cx: &mut ui::Context<Self>,
+    ) -> Option<gpui::Task<gpui::Result<()>>> {
+        let workspace_id = workspace.database_id()?;
+        Some(cx.background_spawn(async move {
+            KEYBINDING_EDITORS
+                .save_keybinding_editor(item_id, workspace_id)
+                .await
+        }))
+    }
+
+    fn should_serialize(&self, _event: &Self::Event) -> bool {
+        false
+    }
+}
+
+mod persistence {
+    use db::{define_connection, query, sqlez_macros::sql};
+    use workspace::WorkspaceDb;
+
+    define_connection! {
+        pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
+            &[sql!(
+                CREATE TABLE keybinding_editors (
+                    workspace_id INTEGER,
+                    item_id INTEGER UNIQUE,
+
+                    PRIMARY KEY(workspace_id, item_id),
+                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
+                ) STRICT;
+            )];
+    }
+
+    impl KeybindingEditorDb {
+        query! {
+            pub async fn save_keybinding_editor(
+                item_id: workspace::ItemId,
+                workspace_id: workspace::WorkspaceId
+            ) -> Result<()> {
+                INSERT OR REPLACE INTO keybinding_editors(item_id, workspace_id)
+                VALUES (?, ?)
+            }
+        }
+
+        query! {
+            pub fn get_keybinding_editor(
+                item_id: workspace::ItemId,
+                workspace_id: workspace::WorkspaceId
+            ) -> Result<Option<workspace::ItemId>> {
+                SELECT item_id
+                FROM keybinding_editors
+                WHERE item_id = ? AND workspace_id = ?
+            }
+        }
+    }
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -20,6 +20,9 @@ use workspace::{Workspace, with_active_or_new_workspace};
 
 use crate::appearance_settings_controls::AppearanceSettingsControls;
 
+pub mod keybindings;
+pub mod ui_components;
+
 pub struct SettingsUiFeatureFlag;
 
 impl FeatureFlag for SettingsUiFeatureFlag {
@@ -121,6 +124,8 @@ pub fn init(cx: &mut App) {
         .detach();
     })
     .detach();
+
+    keybindings::init(cx);
 }
 
 async fn handle_import_vscode_settings(

crates/settings_ui/src/ui_components/table.rs 🔗

@@ -0,0 +1,884 @@
+use std::{ops::Range, rc::Rc, time::Duration};
+
+use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
+use gpui::{
+    AppContext, Axis, Context, Entity, FocusHandle, FontWeight, Length,
+    ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Task, UniformListScrollHandle,
+    WeakEntity, transparent_black, uniform_list,
+};
+use settings::Settings as _;
+use ui::{
+    ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
+    ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
+    InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
+    Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
+    StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
+};
+
+struct UniformListData<const COLS: usize> {
+    render_item_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
+    element_id: ElementId,
+    row_count: usize,
+}
+
+enum TableContents<const COLS: usize> {
+    Vec(Vec<[AnyElement; COLS]>),
+    UniformList(UniformListData<COLS>),
+}
+
+impl<const COLS: usize> TableContents<COLS> {
+    fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> {
+        match self {
+            TableContents::Vec(rows) => Some(rows),
+            TableContents::UniformList(_) => None,
+        }
+    }
+
+    fn len(&self) -> usize {
+        match self {
+            TableContents::Vec(rows) => rows.len(),
+            TableContents::UniformList(data) => data.row_count,
+        }
+    }
+}
+
+pub struct TableInteractionState {
+    pub focus_handle: FocusHandle,
+    pub scroll_handle: UniformListScrollHandle,
+    pub horizontal_scrollbar: ScrollbarProperties,
+    pub vertical_scrollbar: ScrollbarProperties,
+}
+
+impl TableInteractionState {
+    pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
+        cx.new(|cx| {
+            let focus_handle = cx.focus_handle();
+
+            cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, window, cx| {
+                this.hide_scrollbars(window, cx);
+            })
+            .detach();
+
+            let scroll_handle = UniformListScrollHandle::new();
+            let vertical_scrollbar = ScrollbarProperties {
+                axis: Axis::Vertical,
+                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+                show_scrollbar: false,
+                show_track: false,
+                auto_hide: false,
+                hide_task: None,
+            };
+
+            let horizontal_scrollbar = ScrollbarProperties {
+                axis: Axis::Horizontal,
+                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+                show_scrollbar: false,
+                show_track: false,
+                auto_hide: false,
+                hide_task: None,
+            };
+
+            let mut this = Self {
+                focus_handle,
+                scroll_handle,
+                horizontal_scrollbar,
+                vertical_scrollbar,
+            };
+
+            this.update_scrollbar_visibility(cx);
+            this
+        })
+    }
+
+    fn update_scrollbar_visibility(&mut self, cx: &mut Context<Self>) {
+        let show_setting = EditorSettings::get_global(cx).scrollbar.show;
+
+        let scroll_handle = self.scroll_handle.0.borrow();
+
+        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
+            ShowScrollbar::Auto => true,
+            ShowScrollbar::System => cx
+                .try_global::<ScrollbarAutoHide>()
+                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
+            ShowScrollbar::Always => false,
+            ShowScrollbar::Never => false,
+        };
+
+        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
+            (size.contents.width > size.item.width).then_some(size.contents.width)
+        });
+
+        // is there an item long enough that we should show a horizontal scrollbar?
+        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
+            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
+        } else {
+            true
+        };
+
+        let show_scrollbar = match show_setting {
+            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
+            ShowScrollbar::Never => false,
+        };
+        let show_vertical = show_scrollbar;
+
+        let show_horizontal = item_wider_than_container && show_scrollbar;
+
+        let show_horizontal_track =
+            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
+
+        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
+        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
+
+        self.vertical_scrollbar = ScrollbarProperties {
+            axis: self.vertical_scrollbar.axis,
+            state: self.vertical_scrollbar.state.clone(),
+            show_scrollbar: show_vertical,
+            show_track: show_vertical_track,
+            auto_hide: autohide(show_setting, cx),
+            hide_task: None,
+        };
+
+        self.horizontal_scrollbar = ScrollbarProperties {
+            axis: self.horizontal_scrollbar.axis,
+            state: self.horizontal_scrollbar.state.clone(),
+            show_scrollbar: show_horizontal,
+            show_track: show_horizontal_track,
+            auto_hide: autohide(show_setting, cx),
+            hide_task: None,
+        };
+
+        cx.notify();
+    }
+
+    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.horizontal_scrollbar.hide(window, cx);
+        self.vertical_scrollbar.hide(window, cx);
+    }
+
+    // fn listener(this: Entity<Self>, fn: F) ->
+
+    pub fn listener<E: ?Sized>(
+        this: &Entity<Self>,
+        f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static,
+    ) -> impl Fn(&E, &mut Window, &mut App) + 'static {
+        let view = this.downgrade();
+        move |e: &E, window: &mut Window, cx: &mut App| {
+            view.update(cx, |view, cx| f(view, e, window, cx)).ok();
+        }
+    }
+
+    fn render_vertical_scrollbar_track(
+        this: &Entity<Self>,
+        parent: Div,
+        scroll_track_size: Pixels,
+        cx: &mut App,
+    ) -> Div {
+        if !this.read(cx).vertical_scrollbar.show_track {
+            return parent;
+        }
+        let child = v_flex()
+            .h_full()
+            .flex_none()
+            .w(scroll_track_size)
+            .bg(cx.theme().colors().background)
+            .child(
+                div()
+                    .size_full()
+                    .flex_1()
+                    .border_l_1()
+                    .border_color(cx.theme().colors().border),
+            );
+        parent.child(child)
+    }
+
+    fn render_vertical_scrollbar(this: &Entity<Self>, parent: Div, cx: &mut App) -> Div {
+        if !this.read(cx).vertical_scrollbar.show_scrollbar {
+            return parent;
+        }
+        let child = div()
+            .id(("table-vertical-scrollbar", this.entity_id()))
+            .occlude()
+            .flex_none()
+            .h_full()
+            .cursor_default()
+            .absolute()
+            .right_0()
+            .top_0()
+            .bottom_0()
+            .w(px(12.))
+            .on_mouse_move(Self::listener(this, |_, _, _, cx| {
+                cx.notify();
+                cx.stop_propagation()
+            }))
+            .on_hover(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_mouse_up(
+                MouseButton::Left,
+                Self::listener(this, |this, _, window, cx| {
+                    if !this.vertical_scrollbar.state.is_dragging()
+                        && !this.focus_handle.contains_focused(window, cx)
+                    {
+                        this.vertical_scrollbar.hide(window, cx);
+                        cx.notify();
+                    }
+
+                    cx.stop_propagation();
+                }),
+            )
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_scroll_wheel(Self::listener(&this, |_, _, _, cx| {
+                cx.notify();
+            }))
+            .children(Scrollbar::vertical(
+                this.read(cx).vertical_scrollbar.state.clone(),
+            ));
+        parent.child(child)
+    }
+
+    /// Renders the horizontal scrollbar.
+    ///
+    /// The right offset is used to determine how far to the right the
+    /// scrollbar should extend to, useful for ensuring it doesn't collide
+    /// with the vertical scrollbar when visible.
+    fn render_horizontal_scrollbar(
+        this: &Entity<Self>,
+        parent: Div,
+        right_offset: Pixels,
+        cx: &mut App,
+    ) -> Div {
+        if !this.read(cx).horizontal_scrollbar.show_scrollbar {
+            return parent;
+        }
+        let child = div()
+            .id(("table-horizontal-scrollbar", this.entity_id()))
+            .occlude()
+            .flex_none()
+            .w_full()
+            .cursor_default()
+            .absolute()
+            .bottom_neg_px()
+            .left_0()
+            .right_0()
+            .pr(right_offset)
+            .on_mouse_move(Self::listener(this, |_, _, _, cx| {
+                cx.notify();
+                cx.stop_propagation()
+            }))
+            .on_hover(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_mouse_up(
+                MouseButton::Left,
+                Self::listener(this, |this, _, window, cx| {
+                    if !this.horizontal_scrollbar.state.is_dragging()
+                        && !this.focus_handle.contains_focused(window, cx)
+                    {
+                        this.horizontal_scrollbar.hide(window, cx);
+                        cx.notify();
+                    }
+
+                    cx.stop_propagation();
+                }),
+            )
+            .on_scroll_wheel(Self::listener(this, |_, _, _, cx| {
+                cx.notify();
+            }))
+            .children(Scrollbar::horizontal(
+                // percentage as f32..end_offset as f32,
+                this.read(cx).horizontal_scrollbar.state.clone(),
+            ));
+        parent.child(child)
+    }
+
+    fn render_horizontal_scrollbar_track(
+        this: &Entity<Self>,
+        parent: Div,
+        scroll_track_size: Pixels,
+        cx: &mut App,
+    ) -> Div {
+        if !this.read(cx).horizontal_scrollbar.show_track {
+            return parent;
+        }
+        let child = h_flex()
+            .w_full()
+            .h(scroll_track_size)
+            .flex_none()
+            .relative()
+            .child(
+                div()
+                    .w_full()
+                    .flex_1()
+                    // for some reason the horizontal scrollbar is 1px
+                    // taller than the vertical scrollbar??
+                    .h(scroll_track_size - px(1.))
+                    .bg(cx.theme().colors().background)
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border),
+            )
+            .when(this.read(cx).vertical_scrollbar.show_track, |parent| {
+                parent
+                    .child(
+                        div()
+                            .flex_none()
+                            // -1px prevents a missing pixel between the two container borders
+                            .w(scroll_track_size - px(1.))
+                            .h_full(),
+                    )
+                    .child(
+                        // HACK: Fill the missing 1px 🥲
+                        div()
+                            .absolute()
+                            .right(scroll_track_size - px(1.))
+                            .bottom(scroll_track_size - px(1.))
+                            .size_px()
+                            .bg(cx.theme().colors().border),
+                    )
+            });
+
+        parent.child(child)
+    }
+}
+
+/// A table component
+#[derive(RegisterComponent, IntoElement)]
+pub struct Table<const COLS: usize = 3> {
+    striped: bool,
+    width: Option<Length>,
+    headers: Option<[AnyElement; COLS]>,
+    rows: TableContents<COLS>,
+    interaction_state: Option<WeakEntity<TableInteractionState>>,
+    selected_item_index: Option<usize>,
+    column_widths: Option<[Length; COLS]>,
+    on_click_row: Option<Rc<dyn Fn(usize, &mut Window, &mut App)>>,
+}
+
+impl<const COLS: usize> Table<COLS> {
+    /// number of headers provided.
+    pub fn new() -> Self {
+        Table {
+            striped: false,
+            width: None,
+            headers: None,
+            rows: TableContents::Vec(Vec::new()),
+            interaction_state: None,
+            selected_item_index: None,
+            column_widths: None,
+            on_click_row: None,
+        }
+    }
+
+    /// Enables uniform list rendering.
+    /// The provided function will be passed directly to the `uniform_list` element.
+    /// Therefore, if this method is called, any calls to [`Table::row`] before or after
+    /// this method is called will be ignored.
+    pub fn uniform_list(
+        mut self,
+        id: impl Into<ElementId>,
+        row_count: usize,
+        render_item_fn: impl Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>
+        + 'static,
+    ) -> Self {
+        self.rows = TableContents::UniformList(UniformListData {
+            element_id: id.into(),
+            row_count: row_count,
+            render_item_fn: Box::new(render_item_fn),
+        });
+        self
+    }
+
+    /// Enables row striping.
+    pub fn striped(mut self) -> Self {
+        self.striped = true;
+        self
+    }
+
+    /// Sets the width of the table.
+    /// Will enable horizontal scrolling if [`Self::interactable`] is also called.
+    pub fn width(mut self, width: impl Into<Length>) -> Self {
+        self.width = Some(width.into());
+        self
+    }
+
+    /// Enables interaction (primarily scrolling) with the table.
+    ///
+    /// Vertical scrolling will be enabled by default if the table is taller than its container.
+    ///
+    /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise
+    /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`]
+    /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will
+    /// be set to [`ListHorizontalSizingBehavior::FitList`].
+    pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
+        self.interaction_state = Some(interaction_state.downgrade());
+        self
+    }
+
+    pub fn selected_item_index(mut self, selected_item_index: Option<usize>) -> Self {
+        self.selected_item_index = selected_item_index;
+        self
+    }
+
+    pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
+        self.headers = Some(headers.map(IntoElement::into_any_element));
+        self
+    }
+
+    pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self {
+        if let Some(rows) = self.rows.rows_mut() {
+            rows.push(items.map(IntoElement::into_any_element));
+        }
+        self
+    }
+
+    pub fn column_widths(mut self, widths: [impl Into<Length>; COLS]) -> Self {
+        self.column_widths = Some(widths.map(Into::into));
+        self
+    }
+
+    pub fn on_click_row(
+        mut self,
+        callback: impl Fn(usize, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_click_row = Some(Rc::new(callback));
+        self
+    }
+}
+
+fn base_cell_style(width: Option<Length>, cx: &App) -> Div {
+    div()
+        .px_1p5()
+        .when_some(width, |this, width| this.w(width))
+        .when(width.is_none(), |this| this.flex_1())
+        .justify_start()
+        .text_ui(cx)
+        .whitespace_nowrap()
+        .text_ellipsis()
+        .overflow_hidden()
+}
+
+pub fn render_row<const COLS: usize>(
+    row_index: usize,
+    items: [impl IntoElement; COLS],
+    table_context: TableRenderContext<COLS>,
+    cx: &App,
+) -> AnyElement {
+    let is_striped = table_context.striped;
+    let is_last = row_index == table_context.total_row_count - 1;
+    let bg = if row_index % 2 == 1 && is_striped {
+        Some(cx.theme().colors().text.opacity(0.05))
+    } else {
+        None
+    };
+    let column_widths = table_context
+        .column_widths
+        .map_or([None; COLS], |widths| widths.map(Some));
+    let is_selected = table_context.selected_item_index == Some(row_index);
+
+    let row = div()
+        .w_full()
+        .border_2()
+        .border_color(transparent_black())
+        .when(is_selected, |row| {
+            row.border_color(cx.theme().colors().panel_focused_border)
+        })
+        .child(
+            div()
+                .w_full()
+                .flex()
+                .flex_row()
+                .items_center()
+                .justify_between()
+                .px_1p5()
+                .py_1()
+                .when_some(bg, |row, bg| row.bg(bg))
+                .when(!is_striped, |row| {
+                    row.border_b_1()
+                        .border_color(transparent_black())
+                        .when(!is_last, |row| row.border_color(cx.theme().colors().border))
+                })
+                .children(
+                    items
+                        .map(IntoElement::into_any_element)
+                        .into_iter()
+                        .zip(column_widths)
+                        .map(|(cell, width)| base_cell_style(width, cx).child(cell)),
+                ),
+        );
+
+    if let Some(on_click) = table_context.on_click_row {
+        row.id(("table-row", row_index))
+            .on_click(move |_, window, cx| on_click(row_index, window, cx))
+            .into_any_element()
+    } else {
+        row.into_any_element()
+    }
+}
+
+pub fn render_header<const COLS: usize>(
+    headers: [impl IntoElement; COLS],
+    table_context: TableRenderContext<COLS>,
+    cx: &mut App,
+) -> impl IntoElement {
+    let column_widths = table_context
+        .column_widths
+        .map_or([None; COLS], |widths| widths.map(Some));
+    div()
+        .flex()
+        .flex_row()
+        .items_center()
+        .justify_between()
+        .w_full()
+        .p_2()
+        .border_b_1()
+        .border_color(cx.theme().colors().border)
+        .children(headers.into_iter().zip(column_widths).map(|(h, width)| {
+            base_cell_style(width, cx)
+                .font_weight(FontWeight::SEMIBOLD)
+                .child(h)
+        }))
+}
+
+#[derive(Clone)]
+pub struct TableRenderContext<const COLS: usize> {
+    pub striped: bool,
+    pub total_row_count: usize,
+    pub selected_item_index: Option<usize>,
+    pub column_widths: Option<[Length; COLS]>,
+    pub on_click_row: Option<Rc<dyn Fn(usize, &mut Window, &mut App)>>,
+}
+
+impl<const COLS: usize> TableRenderContext<COLS> {
+    fn new(table: &Table<COLS>) -> Self {
+        Self {
+            striped: table.striped,
+            total_row_count: table.rows.len(),
+            column_widths: table.column_widths,
+            selected_item_index: table.selected_item_index,
+            on_click_row: table.on_click_row.clone(),
+        }
+    }
+}
+
+impl<const COLS: usize> RenderOnce for Table<COLS> {
+    fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let table_context = TableRenderContext::new(&self);
+        let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
+
+        let scroll_track_size = px(16.);
+        let h_scroll_offset = if interaction_state
+            .as_ref()
+            .is_some_and(|state| state.read(cx).vertical_scrollbar.show_scrollbar)
+        {
+            // magic number
+            px(3.)
+        } else {
+            px(0.)
+        };
+
+        let width = self.width;
+
+        let table = div()
+            .when_some(width, |this, width| this.w(width))
+            .h_full()
+            .v_flex()
+            .when_some(self.headers.take(), |this, headers| {
+                this.child(render_header(headers, table_context.clone(), cx))
+            })
+            .child(
+                div()
+                    .flex_grow()
+                    .w_full()
+                    .relative()
+                    .overflow_hidden()
+                    .map(|parent| match self.rows {
+                        TableContents::Vec(items) => {
+                            parent.children(items.into_iter().enumerate().map(|(index, row)| {
+                                render_row(index, row, table_context.clone(), cx)
+                            }))
+                        }
+                        TableContents::UniformList(uniform_list_data) => parent.child(
+                            uniform_list(
+                                uniform_list_data.element_id,
+                                uniform_list_data.row_count,
+                                {
+                                    let render_item_fn = uniform_list_data.render_item_fn;
+                                    move |range: Range<usize>, window, cx| {
+                                        let elements = render_item_fn(range.clone(), window, cx);
+                                        elements
+                                            .into_iter()
+                                            .zip(range)
+                                            .map(|(row, row_index)| {
+                                                render_row(
+                                                    row_index,
+                                                    row,
+                                                    table_context.clone(),
+                                                    cx,
+                                                )
+                                            })
+                                            .collect()
+                                    }
+                                },
+                            )
+                            .size_full()
+                            .flex_grow()
+                            .with_sizing_behavior(ListSizingBehavior::Auto)
+                            .with_horizontal_sizing_behavior(if width.is_some() {
+                                ListHorizontalSizingBehavior::Unconstrained
+                            } else {
+                                ListHorizontalSizingBehavior::FitList
+                            })
+                            .when_some(
+                                interaction_state.as_ref(),
+                                |this, state| {
+                                    this.track_scroll(
+                                        state.read_with(cx, |s, _| s.scroll_handle.clone()),
+                                    )
+                                },
+                            ),
+                        ),
+                    })
+                    .when_some(interaction_state.as_ref(), |this, interaction_state| {
+                        this.map(|this| {
+                            TableInteractionState::render_vertical_scrollbar_track(
+                                interaction_state,
+                                this,
+                                scroll_track_size,
+                                cx,
+                            )
+                        })
+                        .map(|this| {
+                            TableInteractionState::render_vertical_scrollbar(
+                                interaction_state,
+                                this,
+                                cx,
+                            )
+                        })
+                    }),
+            )
+            .when_some(
+                width.and(interaction_state.as_ref()),
+                |this, interaction_state| {
+                    this.map(|this| {
+                        TableInteractionState::render_horizontal_scrollbar_track(
+                            interaction_state,
+                            this,
+                            scroll_track_size,
+                            cx,
+                        )
+                    })
+                    .map(|this| {
+                        TableInteractionState::render_horizontal_scrollbar(
+                            interaction_state,
+                            this,
+                            h_scroll_offset,
+                            cx,
+                        )
+                    })
+                },
+            );
+
+        if let Some(interaction_state) = interaction_state.as_ref() {
+            table
+                .track_focus(&interaction_state.read(cx).focus_handle)
+                .id(("table", interaction_state.entity_id()))
+                .on_hover({
+                    let interaction_state = interaction_state.downgrade();
+                    move |hovered, window, cx| {
+                        interaction_state
+                            .update(cx, |interaction_state, cx| {
+                                if *hovered {
+                                    interaction_state.horizontal_scrollbar.show(cx);
+                                    interaction_state.vertical_scrollbar.show(cx);
+                                    cx.notify();
+                                } else if !interaction_state
+                                    .focus_handle
+                                    .contains_focused(window, cx)
+                                {
+                                    interaction_state.hide_scrollbars(window, cx);
+                                }
+                            })
+                            .ok();
+                    }
+                })
+                .into_any_element()
+        } else {
+            table.into_any_element()
+        }
+    }
+}
+
+// computed state related to how to render scrollbars
+// one per axis
+// on render we just read this off the keymap editor
+// we update it when
+// - settings change
+// - on focus in, on focus out, on hover, etc.
+#[derive(Debug)]
+pub struct ScrollbarProperties {
+    axis: Axis,
+    show_scrollbar: bool,
+    show_track: bool,
+    auto_hide: bool,
+    hide_task: Option<Task<()>>,
+    state: ScrollbarState,
+}
+
+impl ScrollbarProperties {
+    // Shows the scrollbar and cancels any pending hide task
+    fn show(&mut self, cx: &mut Context<TableInteractionState>) {
+        if !self.auto_hide {
+            return;
+        }
+        self.show_scrollbar = true;
+        self.hide_task.take();
+        cx.notify();
+    }
+
+    fn hide(&mut self, window: &mut Window, cx: &mut Context<TableInteractionState>) {
+        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
+
+        if !self.auto_hide {
+            return;
+        }
+
+        let axis = self.axis;
+        self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| {
+            cx.background_executor()
+                .timer(SCROLLBAR_SHOW_INTERVAL)
+                .await;
+
+            if let Some(keymap_editor) = keymap_editor.upgrade() {
+                keymap_editor
+                    .update(cx, |keymap_editor, cx| {
+                        match axis {
+                            Axis::Vertical => {
+                                keymap_editor.vertical_scrollbar.show_scrollbar = false
+                            }
+                            Axis::Horizontal => {
+                                keymap_editor.horizontal_scrollbar.show_scrollbar = false
+                            }
+                        }
+                        cx.notify();
+                    })
+                    .ok();
+            }
+        }));
+    }
+}
+
+impl Component for Table<3> {
+    fn scope() -> ComponentScope {
+        ComponentScope::Layout
+    }
+
+    fn description() -> Option<&'static str> {
+        Some("A table component for displaying data in rows and columns with optional styling.")
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        Some(
+            v_flex()
+                .gap_6()
+                .children(vec![
+                    example_group_with_title(
+                        "Basic Tables",
+                        vec![
+                            single_example(
+                                "Simple Table",
+                                Table::new()
+                                    .width(px(400.))
+                                    .header(["Name", "Age", "City"])
+                                    .row(["Alice", "28", "New York"])
+                                    .row(["Bob", "32", "San Francisco"])
+                                    .row(["Charlie", "25", "London"])
+                                    .into_any_element(),
+                            ),
+                            single_example(
+                                "Two Column Table",
+                                Table::new()
+                                    .header(["Category", "Value"])
+                                    .width(px(300.))
+                                    .row(["Revenue", "$100,000"])
+                                    .row(["Expenses", "$75,000"])
+                                    .row(["Profit", "$25,000"])
+                                    .into_any_element(),
+                            ),
+                        ],
+                    ),
+                    example_group_with_title(
+                        "Styled Tables",
+                        vec![
+                            single_example(
+                                "Default",
+                                Table::new()
+                                    .width(px(400.))
+                                    .header(["Product", "Price", "Stock"])
+                                    .row(["Laptop", "$999", "In Stock"])
+                                    .row(["Phone", "$599", "Low Stock"])
+                                    .row(["Tablet", "$399", "Out of Stock"])
+                                    .into_any_element(),
+                            ),
+                            single_example(
+                                "Striped",
+                                Table::new()
+                                    .width(px(400.))
+                                    .striped()
+                                    .header(["Product", "Price", "Stock"])
+                                    .row(["Laptop", "$999", "In Stock"])
+                                    .row(["Phone", "$599", "Low Stock"])
+                                    .row(["Tablet", "$399", "Out of Stock"])
+                                    .row(["Headphones", "$199", "In Stock"])
+                                    .into_any_element(),
+                            ),
+                        ],
+                    ),
+                    example_group_with_title(
+                        "Mixed Content Table",
+                        vec![single_example(
+                            "Table with Elements",
+                            Table::new()
+                                .width(px(840.))
+                                .header(["Status", "Name", "Priority", "Deadline", "Action"])
+                                .row([
+                                    Indicator::dot().color(Color::Success).into_any_element(),
+                                    "Project A".into_any_element(),
+                                    "High".into_any_element(),
+                                    "2023-12-31".into_any_element(),
+                                    Button::new("view_a", "View")
+                                        .style(ButtonStyle::Filled)
+                                        .full_width()
+                                        .into_any_element(),
+                                ])
+                                .row([
+                                    Indicator::dot().color(Color::Warning).into_any_element(),
+                                    "Project B".into_any_element(),
+                                    "Medium".into_any_element(),
+                                    "2024-03-15".into_any_element(),
+                                    Button::new("view_b", "View")
+                                        .style(ButtonStyle::Filled)
+                                        .full_width()
+                                        .into_any_element(),
+                                ])
+                                .row([
+                                    Indicator::dot().color(Color::Error).into_any_element(),
+                                    "Project C".into_any_element(),
+                                    "Low".into_any_element(),
+                                    "2024-06-30".into_any_element(),
+                                    Button::new("view_c", "View")
+                                        .style(ButtonStyle::Filled)
+                                        .full_width()
+                                        .into_any_element(),
+                                ])
+                                .into_any_element(),
+                        )],
+                    ),
+                ])
+                .into_any_element(),
+        )
+    }
+}

crates/ui/src/components.rs 🔗

@@ -32,7 +32,6 @@ mod settings_group;
 mod stack;
 mod tab;
 mod tab_bar;
-mod table;
 mod toggle;
 mod tooltip;
 
@@ -73,7 +72,6 @@ pub use settings_group::*;
 pub use stack::*;
 pub use tab::*;
 pub use tab_bar::*;
-pub use table::*;
 pub use toggle::*;
 pub use tooltip::*;
 

crates/ui/src/components/keybinding.rs 🔗

@@ -8,11 +8,12 @@ use itertools::Itertools;
 
 #[derive(Debug, IntoElement, Clone, RegisterComponent)]
 pub struct KeyBinding {
-    /// A keybinding consists of a key and a set of modifier keys.
-    /// More then one keybinding produces a chord.
+    /// A keybinding consists of a set of keystrokes,
+    /// where each keystroke is a key and a set of modifier keys.
+    /// More than one keystroke produces a chord.
     ///
-    /// This should always contain at least one element.
-    key_binding: gpui::KeyBinding,
+    /// This should always contain at least one keystroke.
+    pub key_binding: gpui::KeyBinding,
 
     /// The [`PlatformStyle`] to use when displaying this keybinding.
     platform_style: PlatformStyle,

crates/ui/src/components/table.rs 🔗

@@ -1,271 +0,0 @@
-use crate::{Indicator, prelude::*};
-use gpui::{AnyElement, FontWeight, IntoElement, Length, div};
-
-/// A table component
-#[derive(IntoElement, RegisterComponent)]
-pub struct Table {
-    column_headers: Vec<SharedString>,
-    rows: Vec<Vec<TableCell>>,
-    column_count: usize,
-    striped: bool,
-    width: Length,
-}
-
-impl Table {
-    /// Create a new table with a column count equal to the
-    /// number of headers provided.
-    pub fn new(headers: Vec<impl Into<SharedString>>) -> Self {
-        let column_count = headers.len();
-
-        Table {
-            column_headers: headers.into_iter().map(Into::into).collect(),
-            column_count,
-            rows: Vec::new(),
-            striped: false,
-            width: Length::Auto,
-        }
-    }
-
-    /// Adds a row to the table.
-    ///
-    /// The row must have the same number of columns as the table.
-    pub fn row(mut self, items: Vec<impl Into<TableCell>>) -> Self {
-        if items.len() == self.column_count {
-            self.rows.push(items.into_iter().map(Into::into).collect());
-        } else {
-            // TODO: Log error: Row length mismatch
-        }
-        self
-    }
-
-    /// Adds multiple rows to the table.
-    ///
-    /// Each row must have the same number of columns as the table.
-    /// Rows that don't match the column count are ignored.
-    pub fn rows(mut self, rows: Vec<Vec<impl Into<TableCell>>>) -> Self {
-        for row in rows {
-            self = self.row(row);
-        }
-        self
-    }
-
-    fn base_cell_style(cx: &mut App) -> Div {
-        div()
-            .px_1p5()
-            .flex_1()
-            .justify_start()
-            .text_ui(cx)
-            .whitespace_nowrap()
-            .text_ellipsis()
-            .overflow_hidden()
-    }
-
-    /// Enables row striping.
-    pub fn striped(mut self) -> Self {
-        self.striped = true;
-        self
-    }
-
-    /// Sets the width of the table.
-    pub fn width(mut self, width: impl Into<Length>) -> Self {
-        self.width = width.into();
-        self
-    }
-}
-
-impl RenderOnce for Table {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        let header = div()
-            .flex()
-            .flex_row()
-            .items_center()
-            .justify_between()
-            .w_full()
-            .p_2()
-            .border_b_1()
-            .border_color(cx.theme().colors().border)
-            .children(self.column_headers.into_iter().map(|h| {
-                Self::base_cell_style(cx)
-                    .font_weight(FontWeight::SEMIBOLD)
-                    .child(h)
-            }));
-
-        let row_count = self.rows.len();
-        let rows = self.rows.into_iter().enumerate().map(|(ix, row)| {
-            let is_last = ix == row_count - 1;
-            let bg = if ix % 2 == 1 && self.striped {
-                Some(cx.theme().colors().text.opacity(0.05))
-            } else {
-                None
-            };
-            div()
-                .w_full()
-                .flex()
-                .flex_row()
-                .items_center()
-                .justify_between()
-                .px_1p5()
-                .py_1()
-                .when_some(bg, |row, bg| row.bg(bg))
-                .when(!is_last, |row| {
-                    row.border_b_1().border_color(cx.theme().colors().border)
-                })
-                .children(row.into_iter().map(|cell| match cell {
-                    TableCell::String(s) => Self::base_cell_style(cx).child(s),
-                    TableCell::Element(e) => Self::base_cell_style(cx).child(e),
-                }))
-        });
-
-        div()
-            .w(self.width)
-            .overflow_hidden()
-            .child(header)
-            .children(rows)
-    }
-}
-
-/// Represents a cell in a table.
-pub enum TableCell {
-    /// A cell containing a string value.
-    String(SharedString),
-    /// A cell containing a UI element.
-    Element(AnyElement),
-}
-
-/// Creates a `TableCell` containing a string value.
-pub fn string_cell(s: impl Into<SharedString>) -> TableCell {
-    TableCell::String(s.into())
-}
-
-/// Creates a `TableCell` containing an element.
-pub fn element_cell(e: impl Into<AnyElement>) -> TableCell {
-    TableCell::Element(e.into())
-}
-
-impl<E> From<E> for TableCell
-where
-    E: Into<SharedString>,
-{
-    fn from(e: E) -> Self {
-        TableCell::String(e.into())
-    }
-}
-
-impl Component for Table {
-    fn scope() -> ComponentScope {
-        ComponentScope::Layout
-    }
-
-    fn description() -> Option<&'static str> {
-        Some("A table component for displaying data in rows and columns with optional styling.")
-    }
-
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        Some(
-            v_flex()
-                .gap_6()
-                .children(vec![
-                    example_group_with_title(
-                        "Basic Tables",
-                        vec![
-                            single_example(
-                                "Simple Table",
-                                Table::new(vec!["Name", "Age", "City"])
-                                    .width(px(400.))
-                                    .row(vec!["Alice", "28", "New York"])
-                                    .row(vec!["Bob", "32", "San Francisco"])
-                                    .row(vec!["Charlie", "25", "London"])
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Two Column Table",
-                                Table::new(vec!["Category", "Value"])
-                                    .width(px(300.))
-                                    .row(vec!["Revenue", "$100,000"])
-                                    .row(vec!["Expenses", "$75,000"])
-                                    .row(vec!["Profit", "$25,000"])
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "Styled Tables",
-                        vec![
-                            single_example(
-                                "Default",
-                                Table::new(vec!["Product", "Price", "Stock"])
-                                    .width(px(400.))
-                                    .row(vec!["Laptop", "$999", "In Stock"])
-                                    .row(vec!["Phone", "$599", "Low Stock"])
-                                    .row(vec!["Tablet", "$399", "Out of Stock"])
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Striped",
-                                Table::new(vec!["Product", "Price", "Stock"])
-                                    .width(px(400.))
-                                    .striped()
-                                    .row(vec!["Laptop", "$999", "In Stock"])
-                                    .row(vec!["Phone", "$599", "Low Stock"])
-                                    .row(vec!["Tablet", "$399", "Out of Stock"])
-                                    .row(vec!["Headphones", "$199", "In Stock"])
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "Mixed Content Table",
-                        vec![single_example(
-                            "Table with Elements",
-                            Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"])
-                                .width(px(840.))
-                                .row(vec![
-                                    element_cell(
-                                        Indicator::dot().color(Color::Success).into_any_element(),
-                                    ),
-                                    string_cell("Project A"),
-                                    string_cell("High"),
-                                    string_cell("2023-12-31"),
-                                    element_cell(
-                                        Button::new("view_a", "View")
-                                            .style(ButtonStyle::Filled)
-                                            .full_width()
-                                            .into_any_element(),
-                                    ),
-                                ])
-                                .row(vec![
-                                    element_cell(
-                                        Indicator::dot().color(Color::Warning).into_any_element(),
-                                    ),
-                                    string_cell("Project B"),
-                                    string_cell("Medium"),
-                                    string_cell("2024-03-15"),
-                                    element_cell(
-                                        Button::new("view_b", "View")
-                                            .style(ButtonStyle::Filled)
-                                            .full_width()
-                                            .into_any_element(),
-                                    ),
-                                ])
-                                .row(vec![
-                                    element_cell(
-                                        Indicator::dot().color(Color::Error).into_any_element(),
-                                    ),
-                                    string_cell("Project C"),
-                                    string_cell("Low"),
-                                    string_cell("2024-06-30"),
-                                    element_cell(
-                                        Button::new("view_c", "View")
-                                            .style(ButtonStyle::Filled)
-                                            .full_width()
-                                            .into_any_element(),
-                                    ),
-                                ])
-                                .into_any_element(),
-                        )],
-                    ),
-                ])
-                .into_any_element(),
-        )
-    }
-}

crates/workspace/src/theme_preview.rs 🔗

@@ -5,8 +5,8 @@ use theme::all_theme_colors;
 use ui::{
     AudioStatus, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
     Checkbox, CheckboxWithLabel, CollaboratorAvailability, ContentGroup, DecoratedIcon,
-    ElevationIndex, Facepile, IconDecoration, Indicator, KeybindingHint, Switch, Table, TintColor,
-    Tooltip, element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio,
+    ElevationIndex, Facepile, IconDecoration, Indicator, KeybindingHint, Switch, TintColor,
+    Tooltip, prelude::*, utils::calculate_contrast_ratio,
 };
 
 use crate::{Item, Workspace};

crates/zed/src/zed.rs 🔗

@@ -1429,6 +1429,8 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec<KeyBinding>) {
         "New Window",
         workspace::NewWindow,
     )]);
+    // todo: nicer api here?
+    settings_ui::keybindings::KeymapEventChannel::trigger_keymap_changed(cx);
 }
 
 pub fn load_default_keymap(cx: &mut App) {