From 00971fbe415fdc4695307f192134093c7bcd138c Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Thu, 6 Feb 2025 16:45:03 -0500 Subject: [PATCH] Introduce KeybindingHint (#24397) - 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 --- 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 +- .../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(-) create mode 100644 crates/ui/src/components/keybinding_hint.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b533345e66aa9a..8ac797b72cbbd5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5613,6 +5613,7 @@ impl Editor { } else { Color::Default }), + None, true, ), )) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4412426c82744d..96d736888e82c6 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5784,6 +5784,7 @@ fn inline_completion_accept_indicator( &accept_keystroke.modifiers, PlatformStyle::platform(), Some(Color::Default), + None, false, )) }) diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index f6626c745b2f31..94ace5632c664b 100644 --- a/crates/ui/src/components.rs +++ b/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::*; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 46f181f3859aa5..c9b61866617731 100644 --- a/crates/ui/src/components/button/button.rs +++ b/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, selected_icon_color: Option, key_binding: Option, + keybinding_position: KeybindingPosition, alpha: Option, } @@ -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( diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 75af3e3a0fab45..0b78be078669ae 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/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] diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index ba4878898b1400..12346026e81000 100644 --- a/crates/ui/src/components/icon.rs +++ b/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) diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index c78fe1524f7ca1..c488e7999b0010 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/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, } 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, + size: Option, ) -> 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, + size: Option, standalone: bool, ) -> impl Iterator { 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, + size: Option, } 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>) -> Self { + self.size = size.into(); + self + } } #[derive(IntoElement)] pub struct KeyIcon { icon: IconName, color: Option, + size: Option, } 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) -> Self { - Self { icon, color } + Self { + icon, + color, + size: None, + } + } + + pub fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self } } diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs new file mode 100644 index 00000000000000..2239cf0790608e --- /dev/null +++ b/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, + suffix: Option, + keybinding: KeyBinding, + size: Option, + elevation: Option, +} + +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, 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) -> 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) -> 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) -> 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>) -> 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>) -> 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> { + "Used to display hint text for keyboard shortcuts. Can have a prefix and suffix." + } + + fn examples(window: &mut Window, _cx: &mut App) -> Vec> { + 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"), + ), + ], + ), + ] + } +} diff --git a/crates/workspace/src/theme_preview.rs b/crates/workspace/src/theme_preview.rs index 5062446fe52859..656fb9a4aca2c8 100644 --- a/crates/workspace/src/theme_preview.rs +++ b/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))