Skip to main content

gpui_kit/overlay/
kbd.rs

1//! Rendering a keyboard shortcut the way the platform writes it.
2
3use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px};
4use gpui_kit_semantics::{NodeSpec, Role, Semantic};
5
6use crate::foundation::{ActiveTheme, Ident, StyledExt};
7use crate::strings::{ActiveStrings, StringKey, Strings};
8
9/// A keyboard shortcut, written the way the current platform writes it.
10///
11/// macOS composes modifiers into one glyph run, while other platforms spell
12/// them out and join with `+`, matching what users read elsewhere in their
13/// system.
14#[derive(Debug, Clone, IntoElement)]
15pub struct Kbd {
16    keystroke: SharedString,
17    ident: Option<Ident>,
18}
19
20impl Kbd {
21    /// Takes a GPUI keystroke such as `cmd-shift-p`.
22    pub fn new(keystroke: impl Into<SharedString>) -> Self {
23        Self {
24            keystroke: keystroke.into(),
25            ident: None,
26        }
27    }
28
29    /// Publishes the shortcut, for hints a test needs to assert. A shortcut
30    /// shown next to the action it belongs to is decorative and needs no id.
31    pub fn id(mut self, ident: impl Into<Ident>) -> Self {
32        self.ident = Some(ident.into());
33        self
34    }
35
36    pub fn caps(&self, cx: &App) -> Vec<SharedString> {
37        caps(
38            self.keystroke.as_ref(),
39            cfg!(target_os = "macos"),
40            cx.strings(),
41        )
42    }
43}
44
45impl RenderOnce for Kbd {
46    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
47        let theme = cx.theme().clone();
48        let published = self.ident.as_ref().map(|ident| {
49            NodeSpec::new(ident.semantic_id(), Role::Text).text(self.keystroke.clone())
50        });
51        let element =
52            div()
53                .row()
54                .gap(px(theme.spacing.xs / 2.0))
55                .children(self.caps(cx).into_iter().map(|cap| {
56                    div()
57                        .h(px(theme
58                            .control
59                            .get(gpui_kit_theme::ControlSize::Sm)
60                            .height))
61                        .min_w(px(theme
62                            .control
63                            .get(gpui_kit_theme::ControlSize::Sm)
64                            .height))
65                        .px(px(theme.spacing.xs))
66                        .flex()
67                        .items_center()
68                        .justify_center()
69                        .radius(&theme, gpui_kit_theme::Radius::Small)
70                        .bg(theme.colors.hover)
71                        .font_family(theme.typography.mono.clone())
72                        .text_size(px(theme.typography.caption.size))
73                        .text_color(theme.colors.text_muted)
74                        .child(cap)
75                }));
76        match published {
77            Some(spec) => element.semantic_in(cx, spec).into_any_element(),
78            None => element.into_any_element(),
79        }
80    }
81}
82
83/// Splits a keystroke into the caps to draw.
84///
85/// Written as a free function so the platform choice can be tested on any host.
86pub fn caps(keystroke: &str, macos: bool, strings: &Strings) -> Vec<SharedString> {
87    let mut modifiers = String::new();
88    let mut caps: Vec<SharedString> = Vec::new();
89    let parts: Vec<&str> = keystroke
90        .split('-')
91        .filter(|part| !part.is_empty())
92        .collect();
93    let Some((key, modifier_parts)) = parts.split_last() else {
94        return Vec::new();
95    };
96
97    for modifier in modifier_parts {
98        let label = modifier_label(modifier, macos, strings);
99        if macos {
100            modifiers.push_str(&label);
101        } else {
102            caps.push(label.into());
103        }
104    }
105    let key = key_label(key, macos);
106    if macos {
107        modifiers.push_str(&key);
108        vec![modifiers.into()]
109    } else {
110        caps.push(key.into());
111        caps
112    }
113}
114
115/// The symbol forms are only ever reached under macOS, where they are what a
116/// keyboard shortcut is expected to look like.
117///
118/// The Geist faces draw `⇧` and `⇥` but none of the others, so the asset crate
119/// bundles a small fallback face for the remainder. Leaving that to whatever
120/// font the host machine happened to install made this component's output
121/// depend on the machine rather than on the caller's data.
122fn modifier_label(modifier: &str, macos: bool, strings: &Strings) -> String {
123    match (modifier, macos) {
124        ("cmd" | "super" | "win", true) => "⌘".into(),
125        ("cmd" | "super" | "win", false) => strings.text(StringKey::KbdSuper).to_string(),
126        ("ctrl" | "control", true) => "⌃".into(),
127        ("ctrl" | "control", false) => strings.text(StringKey::KbdControl).to_string(),
128        ("alt" | "option", true) => "⌥".into(),
129        ("alt" | "option", false) => strings.text(StringKey::KbdAlt).to_string(),
130        ("shift", true) => "⇧".into(),
131        ("shift", false) => strings.text(StringKey::KbdShift).to_string(),
132        (other, _) => capitalize(other),
133    }
134}
135
136fn key_label(key: &str, macos: bool) -> String {
137    match (key, macos) {
138        // U+23CE, not U+21A9: the bundled mono face draws the hooked arrow as
139        // a shape that reads as something other than a return key.
140        ("enter", true) => "⏎".into(),
141        ("escape", true) => "esc".into(),
142        ("backspace", true) => "⌫".into(),
143        ("delete", true) => "⌦".into(),
144        ("tab", true) => "⇥".into(),
145        ("up", _) => "↑".into(),
146        ("down", _) => "↓".into(),
147        ("left", _) => "←".into(),
148        ("right", _) => "→".into(),
149        ("space", _) => "␣".into(),
150        (other, _) if other.chars().count() == 1 => other.to_uppercase(),
151        (other, _) => capitalize(other),
152    }
153}
154
155fn capitalize(value: &str) -> String {
156    let mut characters = value.chars();
157    match characters.next() {
158        Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
159        None => String::new(),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn macos_composes_modifiers_into_one_cap() {
169        assert_eq!(
170            caps("cmd-shift-p", true, &Strings::new()),
171            vec![SharedString::from("⌘⇧P")]
172        );
173    }
174
175    #[test]
176    fn other_platforms_spell_each_modifier_out() {
177        assert_eq!(
178            caps("ctrl-shift-p", false, &Strings::new()),
179            vec![
180                SharedString::from("Ctrl"),
181                SharedString::from("Shift"),
182                SharedString::from("P")
183            ]
184        );
185    }
186
187    #[test]
188    fn named_keys_use_their_symbols_where_the_platform_expects_them() {
189        assert_eq!(
190            caps("enter", true, &Strings::new()),
191            vec![SharedString::from("⏎")]
192        );
193        assert_eq!(
194            caps("enter", false, &Strings::new()),
195            vec![SharedString::from("Enter")]
196        );
197        assert_eq!(
198            caps("up", false, &Strings::new()),
199            vec![SharedString::from("↑")]
200        );
201    }
202
203    #[test]
204    fn an_empty_keystroke_draws_nothing() {
205        assert!(caps("", true, &Strings::new()).is_empty());
206    }
207}