Introduce KeybindingHint (#24397)

Nate Butler created

- Implements scaling for `ui::Keybinding` and it's component parts
- Adds the `ui::KeybindingHint` component for creating keybinding hints
easily:

![CleanShot 2025-02-04 at 16 59
38@2x](https://github.com/user-attachments/assets/d781e401-8875-4edc-a4b0-5f8750777d86)

Release Notes:

- N/A

Change summary

crates/editor/src/editor.rs                    |   1 
crates/editor/src/element.rs                   |   1 
crates/ui/src/components.rs                    |   2 
crates/ui/src/components/button/button.rs      |  18 +
crates/ui/src/components/button/button_like.rs |   7 
crates/ui/src/components/icon.rs               |   4 
crates/ui/src/components/keybinding.rs         |  63 +++
crates/ui/src/components/keybinding_hint.rs    | 307 ++++++++++++++++++++
crates/workspace/src/theme_preview.rs          |   3 
9 files changed, 390 insertions(+), 16 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -5784,6 +5784,7 @@ fn inline_completion_accept_indicator(
                 &accept_keystroke.modifiers,
                 PlatformStyle::platform(),
                 Some(Color::Default),
+                None,
                 false,
             ))
         })

crates/ui/src/components.rs 🔗

@@ -11,6 +11,7 @@ mod image;
 mod indent_guides;
 mod indicator;
 mod keybinding;
+mod keybinding_hint;
 mod label;
 mod list;
 mod modal;
@@ -47,6 +48,7 @@ pub use image::*;
 pub use indent_guides::*;
 pub use indicator::*;
 pub use keybinding::*;
+pub use keybinding_hint::*;
 pub use label::*;
 pub use list::*;
 pub use modal::*;

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

@@ -2,7 +2,8 @@
 use gpui::{AnyView, DefiniteLength};
 
 use crate::{
-    prelude::*, Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, TintColor,
+    prelude::*, Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding,
+    KeybindingPosition, TintColor,
 };
 use crate::{
     ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle,
@@ -92,6 +93,7 @@ pub struct Button {
     selected_icon: Option<IconName>,
     selected_icon_color: Option<Color>,
     key_binding: Option<KeyBinding>,
+    keybinding_position: KeybindingPosition,
     alpha: Option<f32>,
 }
 
@@ -117,6 +119,7 @@ impl Button {
             selected_icon: None,
             selected_icon_color: None,
             key_binding: None,
+            keybinding_position: KeybindingPosition::default(),
             alpha: None,
         }
     }
@@ -187,6 +190,15 @@ impl Button {
         self
     }
 
+    /// Sets the position of the keybinding relative to the button label.
+    ///
+    /// This method allows you to specify where the keybinding should be displayed
+    /// in relation to the button's label.
+    pub fn key_binding_position(mut self, position: KeybindingPosition) -> Self {
+        self.keybinding_position = position;
+        self
+    }
+
     /// Sets the alpha property of the color of label.
     pub fn alpha(mut self, alpha: f32) -> Self {
         self.alpha = Some(alpha);
@@ -412,6 +424,10 @@ impl RenderOnce for Button {
                 })
                 .child(
                     h_flex()
+                        .when(
+                            self.keybinding_position == KeybindingPosition::Start,
+                            |this| this.flex_row_reverse(),
+                        )
                         .gap(DynamicSpacing::Base06.rems(cx))
                         .justify_between()
                         .child(

crates/ui/src/components/button/button_like.rs 🔗

@@ -45,6 +45,13 @@ pub enum IconPosition {
     End,
 }
 
+#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum KeybindingPosition {
+    Start,
+    #[default]
+    End,
+}
+
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 pub enum TintColor {
     #[default]

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

@@ -70,6 +70,7 @@ pub enum IconSize {
     Medium,
     /// 48px
     XLarge,
+    Custom(Pixels),
 }
 
 impl IconSize {
@@ -80,6 +81,7 @@ impl IconSize {
             IconSize::Small => rems_from_px(14.),
             IconSize::Medium => rems_from_px(16.),
             IconSize::XLarge => rems_from_px(48.),
+            IconSize::Custom(size) => rems_from_px(size.into()),
         }
     }
 
@@ -96,6 +98,8 @@ impl IconSize {
             IconSize::Small => DynamicSpacing::Base02.px(cx),
             IconSize::Medium => DynamicSpacing::Base02.px(cx),
             IconSize::XLarge => DynamicSpacing::Base02.px(cx),
+            // TODO: Wire into dynamic spacing
+            IconSize::Custom(size) => px(size.into()),
         };
 
         (icon_size, padding)

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

@@ -15,6 +15,7 @@ pub struct KeyBinding {
 
     /// The [`PlatformStyle`] to use when displaying this keybinding.
     platform_style: PlatformStyle,
+    size: Option<Pixels>,
 }
 
 impl KeyBinding {
@@ -47,6 +48,7 @@ impl KeyBinding {
         Self {
             key_binding,
             platform_style: PlatformStyle::platform(),
+            size: None,
         }
     }
 
@@ -55,6 +57,12 @@ impl KeyBinding {
         self.platform_style = platform_style;
         self
     }
+
+    /// Sets the size for this [`KeyBinding`].
+    pub fn size(mut self, size: Pixels) -> Self {
+        self.size = Some(size);
+        self
+    }
 }
 
 impl RenderOnce for KeyBinding {
@@ -83,9 +91,12 @@ impl RenderOnce for KeyBinding {
                         &keystroke.modifiers,
                         self.platform_style,
                         None,
+                        self.size,
                         false,
                     ))
-                    .map(|el| el.child(render_key(&keystroke, self.platform_style, None)))
+                    .map(|el| {
+                        el.child(render_key(&keystroke, self.platform_style, None, self.size))
+                    })
             }))
     }
 }
@@ -94,11 +105,14 @@ pub fn render_key(
     keystroke: &Keystroke,
     platform_style: PlatformStyle,
     color: Option<Color>,
+    size: Option<Pixels>,
 ) -> AnyElement {
     let key_icon = icon_for_key(keystroke, platform_style);
     match key_icon {
-        Some(icon) => KeyIcon::new(icon, color).into_any_element(),
-        None => Key::new(capitalize(&keystroke.key), color).into_any_element(),
+        Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
+        None => Key::new(capitalize(&keystroke.key), color)
+            .size(size)
+            .into_any_element(),
     }
 }
 
@@ -130,6 +144,7 @@ pub fn render_modifiers(
     modifiers: &Modifiers,
     platform_style: PlatformStyle,
     color: Option<Color>,
+    size: Option<Pixels>,
     standalone: bool,
 ) -> impl Iterator<Item = AnyElement> {
     enum KeyOrIcon {
@@ -200,8 +215,8 @@ pub fn render_modifiers(
             PlatformStyle::Windows => vec![modifier.windows, KeyOrIcon::Key("+")],
         })
         .map(move |key_or_icon| match key_or_icon {
-            KeyOrIcon::Key(key) => Key::new(key, color).into_any_element(),
-            KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).into_any_element(),
+            KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(),
+            KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
         })
 }
 
@@ -209,26 +224,26 @@ pub fn render_modifiers(
 pub struct Key {
     key: SharedString,
     color: Option<Color>,
+    size: Option<Pixels>,
 }
 
 impl RenderOnce for Key {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         let single_char = self.key.len() == 1;
+        let size = self.size.unwrap_or(px(14.));
+        let size_f32: f32 = size.into();
 
         div()
             .py_0()
             .map(|this| {
                 if single_char {
-                    this.w(rems_from_px(14.))
-                        .flex()
-                        .flex_none()
-                        .justify_center()
+                    this.w(size).flex().flex_none().justify_center()
                 } else {
                     this.px_0p5()
                 }
             })
-            .h(rems_from_px(14.))
-            .text_ui(cx)
+            .h(rems_from_px(size_f32))
+            .text_size(size)
             .line_height(relative(1.))
             .text_color(self.color.unwrap_or(Color::Muted).color(cx))
             .child(self.key.clone())
@@ -240,27 +255,47 @@ impl Key {
         Self {
             key: key.into(),
             color,
+            size: None,
         }
     }
+
+    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.size = size.into();
+        self
+    }
 }
 
 #[derive(IntoElement)]
 pub struct KeyIcon {
     icon: IconName,
     color: Option<Color>,
+    size: Option<Pixels>,
 }
 
 impl RenderOnce for KeyIcon {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let size = self
+            .size
+            .unwrap_or(IconSize::Small.rems().to_pixels(window.rem_size()));
+
         Icon::new(self.icon)
-            .size(IconSize::XSmall)
+            .size(IconSize::Custom(size))
             .color(self.color.unwrap_or(Color::Muted))
     }
 }
 
 impl KeyIcon {
     pub fn new(icon: IconName, color: Option<Color>) -> Self {
-        Self { icon, color }
+        Self {
+            icon,
+            color,
+            size: None,
+        }
+    }
+
+    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.size = size.into();
+        self
     }
 }
 

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

@@ -0,0 +1,307 @@
+use crate::{h_flex, prelude::*};
+use crate::{ElevationIndex, KeyBinding};
+use gpui::{point, App, BoxShadow, IntoElement, Window};
+use smallvec::smallvec;
+
+/// Represents a hint for a keybinding, optionally with a prefix and suffix.
+///
+/// This struct allows for the creation and customization of a keybinding hint,
+/// which can be used to display keyboard shortcuts or commands in a user interface.
+///
+/// # Examples
+///
+/// ```
+/// use ui::prelude::*;
+///
+/// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+S"))
+///     .prefix("Save:")
+///     .size(Pixels::from(14.0));
+/// ```
+#[derive(Debug, IntoElement, Clone)]
+pub struct KeybindingHint {
+    prefix: Option<SharedString>,
+    suffix: Option<SharedString>,
+    keybinding: KeyBinding,
+    size: Option<Pixels>,
+    elevation: Option<ElevationIndex>,
+}
+
+impl KeybindingHint {
+    /// Creates a new `KeybindingHint` with the specified keybinding.
+    ///
+    /// This method initializes a new `KeybindingHint` instance with the given keybinding,
+    /// setting all other fields to their default values.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+C"));
+    /// ```
+    pub fn new(keybinding: KeyBinding) -> Self {
+        Self {
+            prefix: None,
+            suffix: None,
+            keybinding,
+            size: None,
+            elevation: None,
+        }
+    }
+
+    /// Creates a new `KeybindingHint` with a prefix and keybinding.
+    ///
+    /// This method initializes a new `KeybindingHint` instance with the given prefix and keybinding,
+    /// setting all other fields to their default values.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::with_prefix("Copy:", KeyBinding::from_str("Ctrl+C"));
+    /// ```
+    pub fn with_prefix(prefix: impl Into<SharedString>, keybinding: KeyBinding) -> Self {
+        Self {
+            prefix: Some(prefix.into()),
+            suffix: None,
+            keybinding,
+            size: None,
+            elevation: None,
+        }
+    }
+
+    /// Creates a new `KeybindingHint` with a keybinding and suffix.
+    ///
+    /// This method initializes a new `KeybindingHint` instance with the given keybinding and suffix,
+    /// setting all other fields to their default values.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::with_suffix(KeyBinding::from_str("Ctrl+V"), "Paste");
+    /// ```
+    pub fn with_suffix(keybinding: KeyBinding, suffix: impl Into<SharedString>) -> Self {
+        Self {
+            prefix: None,
+            suffix: Some(suffix.into()),
+            keybinding,
+            size: None,
+            elevation: None,
+        }
+    }
+
+    /// Sets the prefix for the keybinding hint.
+    ///
+    /// This method allows adding or changing the prefix text that appears before the keybinding.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+X"))
+    ///     .prefix("Cut:");
+    /// ```
+    pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
+        self.prefix = Some(prefix.into());
+        self
+    }
+
+    /// Sets the suffix for the keybinding hint.
+    ///
+    /// This method allows adding or changing the suffix text that appears after the keybinding.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+F"))
+    ///     .suffix("Find");
+    /// ```
+    pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
+        self.suffix = Some(suffix.into());
+        self
+    }
+
+    /// Sets the size of the keybinding hint.
+    ///
+    /// This method allows specifying the size of the keybinding hint in pixels.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+Z"))
+    ///     .size(Pixels::from(16.0));
+    /// ```
+    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.size = size.into();
+        self
+    }
+
+    /// Sets the elevation of the keybinding hint.
+    ///
+    /// This method allows specifying the elevation index for the keybinding hint,
+    /// which affects its visual appearance in terms of depth or layering.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let hint = KeybindingHint::new(KeyBinding::from_str("Ctrl+A"))
+    ///     .elevation(ElevationIndex::new(1));
+    /// ```
+    pub fn elevation(mut self, elevation: impl Into<Option<ElevationIndex>>) -> Self {
+        self.elevation = elevation.into();
+        self
+    }
+}
+
+impl RenderOnce for KeybindingHint {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let colors = cx.theme().colors().clone();
+
+        let size = self
+            .size
+            .unwrap_or(TextSize::Small.rems(cx).to_pixels(window.rem_size()));
+        let kb_size = size - px(2.0);
+        let kb_bg = if let Some(elevation) = self.elevation {
+            elevation.on_elevation_bg(cx)
+        } else {
+            theme::color_alpha(colors.element_background, 0.6)
+        };
+
+        h_flex()
+            .items_center()
+            .gap_0p5()
+            .font_buffer(cx)
+            .text_size(size)
+            .text_color(colors.text_muted)
+            .children(self.prefix)
+            .child(
+                h_flex()
+                    .items_center()
+                    .rounded_md()
+                    .px_0p5()
+                    .mr_0p5()
+                    .border_1()
+                    .border_color(kb_bg)
+                    .bg(kb_bg.opacity(0.8))
+                    .shadow(smallvec![BoxShadow {
+                        color: cx.theme().colors().editor_background.opacity(0.8),
+                        offset: point(px(0.), px(1.)),
+                        blur_radius: px(0.),
+                        spread_radius: px(0.),
+                    }])
+                    .child(self.keybinding.size(kb_size)),
+            )
+            .children(self.suffix)
+    }
+}
+
+impl ComponentPreview for KeybindingHint {
+    fn description() -> impl Into<Option<&'static str>> {
+        "Used to display hint text for keyboard shortcuts. Can have a prefix and suffix."
+    }
+
+    fn examples(window: &mut Window, _cx: &mut App) -> Vec<ComponentExampleGroup<Self>> {
+        let home_fallback = gpui::KeyBinding::new("home", menu::SelectFirst, None);
+        let home = KeyBinding::for_action(&menu::SelectFirst, window)
+            .unwrap_or(KeyBinding::new(home_fallback));
+
+        let end_fallback = gpui::KeyBinding::new("end", menu::SelectLast, None);
+        let end = KeyBinding::for_action(&menu::SelectLast, window)
+            .unwrap_or(KeyBinding::new(end_fallback));
+
+        let enter_fallback = gpui::KeyBinding::new("enter", menu::Confirm, None);
+        let enter = KeyBinding::for_action(&menu::Confirm, window)
+            .unwrap_or(KeyBinding::new(enter_fallback));
+
+        let escape_fallback = gpui::KeyBinding::new("escape", menu::Cancel, None);
+        let escape = KeyBinding::for_action(&menu::Cancel, window)
+            .unwrap_or(KeyBinding::new(escape_fallback));
+
+        vec![
+            example_group_with_title(
+                "Basic",
+                vec![
+                    single_example(
+                        "With Prefix",
+                        KeybindingHint::with_prefix("Go to Start:", home.clone()),
+                    ),
+                    single_example(
+                        "With Suffix",
+                        KeybindingHint::with_suffix(end.clone(), "Go to End"),
+                    ),
+                    single_example(
+                        "With Prefix and Suffix",
+                        KeybindingHint::new(enter.clone())
+                            .prefix("Confirm:")
+                            .suffix("Execute selected action"),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "Sizes",
+                vec![
+                    single_example(
+                        "Small",
+                        KeybindingHint::new(home.clone())
+                            .size(Pixels::from(12.0))
+                            .prefix("Small:"),
+                    ),
+                    single_example(
+                        "Medium",
+                        KeybindingHint::new(end.clone())
+                            .size(Pixels::from(16.0))
+                            .suffix("Medium"),
+                    ),
+                    single_example(
+                        "Large",
+                        KeybindingHint::new(enter.clone())
+                            .size(Pixels::from(20.0))
+                            .prefix("Large:")
+                            .suffix("Size"),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "Elevations",
+                vec![
+                    single_example(
+                        "Surface",
+                        KeybindingHint::new(home.clone())
+                            .elevation(ElevationIndex::Surface)
+                            .prefix("Surface:"),
+                    ),
+                    single_example(
+                        "Elevated Surface",
+                        KeybindingHint::new(end.clone())
+                            .elevation(ElevationIndex::ElevatedSurface)
+                            .suffix("Elevated"),
+                    ),
+                    single_example(
+                        "Editor Surface",
+                        KeybindingHint::new(enter.clone())
+                            .elevation(ElevationIndex::EditorSurface)
+                            .prefix("Editor:")
+                            .suffix("Surface"),
+                    ),
+                    single_example(
+                        "Modal Surface",
+                        KeybindingHint::new(escape.clone())
+                            .elevation(ElevationIndex::ModalSurface)
+                            .prefix("Modal:")
+                            .suffix("Escape"),
+                    ),
+                ],
+            ),
+        ]
+    }
+}

crates/workspace/src/theme_preview.rs 🔗

@@ -6,7 +6,7 @@ use ui::{
     element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio, AudioStatus,
     Availability, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
     Checkbox, CheckboxWithLabel, ContentGroup, DecoratedIcon, ElevationIndex, Facepile,
-    IconDecoration, Indicator, Switch, Table, TintColor, Tooltip,
+    IconDecoration, Indicator, KeybindingHint, Switch, Table, TintColor, Tooltip,
 };
 
 use crate::{Item, Workspace};
@@ -408,6 +408,7 @@ impl ThemePreview {
             .child(Facepile::render_component_previews(window, cx))
             .child(Icon::render_component_previews(window, cx))
             .child(IconDecoration::render_component_previews(window, cx))
+            .child(KeybindingHint::render_component_previews(window, cx))
             .child(Indicator::render_component_previews(window, cx))
             .child(Switch::render_component_previews(window, cx))
             .child(Table::render_component_previews(window, cx))