keymap_ui: Hover tooltip for action documentation (#33862)

Ben Kunkle created

Closes #ISSUE

Show the documentation for an action when hovered. As a bonus, also show
the humanized command palette name!

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

crates/gpui/src/action.rs                     |  9 ++++++
crates/gpui/src/app.rs                        |  7 ++++
crates/settings_ui/src/keybindings.rs         | 31 ++++++++++++++++++--
crates/settings_ui/src/ui_components/table.rs |  4 --
4 files changed, 44 insertions(+), 7 deletions(-)

Detailed changes

crates/gpui/src/action.rs 🔗

@@ -225,6 +225,7 @@ pub(crate) struct ActionRegistry {
     all_names: Vec<&'static str>, // So we can return a static slice.
     deprecated_aliases: HashMap<&'static str, &'static str>, // deprecated name -> preferred name
     deprecation_messages: HashMap<&'static str, &'static str>, // action name -> deprecation message
+    documentation: HashMap<&'static str, &'static str>, // action name -> documentation
 }
 
 impl Default for ActionRegistry {
@@ -232,6 +233,7 @@ impl Default for ActionRegistry {
         let mut this = ActionRegistry {
             by_name: Default::default(),
             names_by_type_id: Default::default(),
+            documentation: Default::default(),
             all_names: Default::default(),
             deprecated_aliases: Default::default(),
             deprecation_messages: Default::default(),
@@ -327,6 +329,9 @@ impl ActionRegistry {
         if let Some(deprecation_msg) = action.deprecation_message {
             self.deprecation_messages.insert(name, deprecation_msg);
         }
+        if let Some(documentation) = action.documentation {
+            self.documentation.insert(name, documentation);
+        }
     }
 
     /// Construct an action based on its name and optional JSON parameters sourced from the keymap.
@@ -388,6 +393,10 @@ impl ActionRegistry {
     pub fn deprecation_messages(&self) -> &HashMap<&'static str, &'static str> {
         &self.deprecation_messages
     }
+
+    pub fn documentation(&self) -> &HashMap<&'static str, &'static str> {
+        &self.documentation
+    }
 }
 
 /// Generate a list of all the registered actions.

crates/gpui/src/app.rs 🔗

@@ -1403,11 +1403,16 @@ impl App {
         self.actions.deprecated_aliases()
     }
 
-    /// Get a list of all action deprecation messages.
+    /// Get a map from an action name to the deprecation messages.
     pub fn action_deprecation_messages(&self) -> &HashMap<&'static str, &'static str> {
         self.actions.deprecation_messages()
     }
 
+    /// Get a map from an action name to the documentation.
+    pub fn action_documentation(&self) -> &HashMap<&'static str, &'static str> {
+        self.actions.documentation()
+    }
+
     /// Register a callback to be invoked when the application is about to quit.
     /// It is not possible to cancel the quit event at this point.
     pub fn on_app_quit<Fut>(

crates/settings_ui/src/keybindings.rs 🔗

@@ -254,7 +254,9 @@ impl KeymapEditor {
         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 unmapped_action_names =
+            HashSet::from_iter(cx.all_action_names().into_iter().copied());
+        let action_documentation = cx.action_documentation();
 
         let mut processed_bindings = Vec::new();
         let mut string_match_candidates = Vec::new();
@@ -280,6 +282,7 @@ impl KeymapEditor {
             let action_input = key_binding
                 .action_input()
                 .map(|input| SyntaxHighlightedText::new(input, json_language.clone()));
+            let action_docs = action_documentation.get(action_name).copied();
 
             let index = processed_bindings.len();
             let string_match_candidate = StringMatchCandidate::new(index, &action_name);
@@ -288,6 +291,7 @@ impl KeymapEditor {
                 ui_key_binding,
                 action: action_name.into(),
                 action_input,
+                action_docs,
                 context: Some(context),
                 source,
             });
@@ -301,8 +305,9 @@ impl KeymapEditor {
             processed_bindings.push(ProcessedKeybinding {
                 keystroke_text: empty.clone(),
                 ui_key_binding: None,
-                action: (*action_name).into(),
+                action: action_name.into(),
                 action_input: None,
+                action_docs: action_documentation.get(action_name).copied(),
                 context: None,
                 source: None,
             });
@@ -537,6 +542,7 @@ struct ProcessedKeybinding {
     ui_key_binding: Option<ui::KeyBinding>,
     action: SharedString,
     action_input: Option<SyntaxHighlightedText>,
+    action_docs: Option<&'static str>,
     context: Option<KeybindContextString>,
     source: Option<(KeybindSource, SharedString)>,
 }
@@ -635,7 +641,26 @@ impl Render for KeymapEditor {
                                     let candidate_id = this.matches.get(index)?.candidate_id;
                                     let binding = &this.keybindings[candidate_id];
 
-                                    let action = binding.action.clone().into_any_element();
+                                    let action = div()
+                                        .child(binding.action.clone())
+                                        .id(("keymap action", index))
+                                        .tooltip({
+                                            let action_name = binding.action.clone();
+                                            let action_docs = binding.action_docs;
+                                            move |_, cx| {
+                                                let action_tooltip = Tooltip::new(
+                                                    command_palette::humanize_action_name(
+                                                        &action_name,
+                                                    ),
+                                                );
+                                                let action_tooltip = match action_docs {
+                                                    Some(docs) => action_tooltip.meta(docs),
+                                                    None => action_tooltip,
+                                                };
+                                                cx.new(|_| action_tooltip).into()
+                                            }
+                                        })
+                                        .into_any_element();
                                     let keystrokes = binding.ui_key_binding.clone().map_or(
                                         binding.keystroke_text.clone().into_any_element(),
                                         IntoElement::into_any_element,

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

@@ -12,8 +12,7 @@ use ui::{
     ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
     InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
     Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
-    StyledTypography, Tooltip, Window, div, example_group_with_title, h_flex, px, single_example,
-    v_flex,
+    StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
 };
 
 struct UniformListData<const COLS: usize> {
@@ -474,7 +473,6 @@ pub fn render_row<const COLS: usize>(
     let row = div().w_full().child(
         h_flex()
             .id("table_row")
-            .tooltip(Tooltip::text("Hit enter to edit"))
             .w_full()
             .justify_between()
             .px_1p5()