Skip to main content

gpui_component/
kbd.rs

1use gpui::{
2    Action, AsKeystroke, FocusHandle, Half, InteractiveElement as _, IntoElement, KeyBinding,
3    KeyContext, Keystroke, ParentElement as _, RenderOnce, StyleRefinement, Styled, Window, div,
4    prelude::FluentBuilder as _, relative,
5};
6
7use gpui_base::TestTextExt as _;
8
9use crate::{ActiveTheme, StyledExt};
10
11/// A tag for displaying keyboard keybindings.
12#[derive(IntoElement, Clone, Debug)]
13pub struct Kbd {
14    style: StyleRefinement,
15    stroke: Keystroke,
16    appearance: bool,
17    outline: bool,
18}
19
20impl From<Keystroke> for Kbd {
21    fn from(stroke: Keystroke) -> Self {
22        Self {
23            style: StyleRefinement::default(),
24            stroke,
25            appearance: true,
26            outline: false,
27        }
28    }
29}
30
31impl Kbd {
32    /// Create a new Kbd element with the given [`Keystroke`].
33    pub fn new(stroke: Keystroke) -> Self {
34        Self {
35            style: StyleRefinement::default(),
36            stroke,
37            appearance: true,
38            outline: false,
39        }
40    }
41
42    /// The keystroke this tag shows.
43    pub(crate) fn keystroke(&self) -> &Keystroke {
44        &self.stroke
45    }
46
47    /// Set the appearance of the keybinding, default is `true`.
48    pub fn appearance(mut self, appearance: bool) -> Self {
49        self.appearance = appearance;
50        self
51    }
52
53    /// Use outline style for the keybinding, default is `false`.
54    pub fn outline(mut self) -> Self {
55        self.outline = true;
56        self
57    }
58
59    /// Return the first keybinding for the given action and context.
60    pub fn binding_for_action(
61        action: &dyn Action,
62        context: Option<&str>,
63        window: &Window,
64    ) -> Option<Self> {
65        let key_context = context.and_then(|context| KeyContext::parse(context).ok());
66        let binding = match key_context {
67            Some(context) => {
68                window.highest_precedence_binding_for_action_in_context(action, context)
69            }
70            None => window.highest_precedence_binding_for_action(action),
71        }?;
72
73        Self::from_binding(&binding)
74    }
75
76    /// Return the first keybinding for the given action and focus handle.
77    ///
78    /// GPUI resolves the handle in the previously rendered frame, so this
79    /// finds nothing for a handle whose element is drawn for the first time
80    /// in the current frame.
81    pub fn binding_for_action_in(
82        action: &dyn Action,
83        focus_handle: &FocusHandle,
84        window: &Window,
85    ) -> Option<Self> {
86        let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
87        Self::from_binding(&binding)
88    }
89
90    /// Return the first keybinding for the given action that was registered
91    /// without a key context, so it applies wherever focus is.
92    pub fn global_binding_for_action(action: &dyn Action, window: &Window) -> Option<Self> {
93        let binding = window
94            .highest_precedence_binding_for_action_in_context(action, KeyContext::default())?;
95        Self::from_binding(&binding)
96    }
97
98    fn from_binding(binding: &KeyBinding) -> Option<Self> {
99        let key = binding.keystrokes().first()?;
100        Some(Self::new(key.as_keystroke().clone()))
101    }
102
103    /// Return the Platform specific keybinding string by KeyStroke
104    ///
105    /// macOS: https://support.apple.com/en-us/HT201236
106    /// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
107    pub fn format(key: &Keystroke) -> String {
108        #[cfg(target_os = "macos")]
109        const SEPARATOR: &str = "";
110        #[cfg(not(target_os = "macos"))]
111        const SEPARATOR: &str = "+";
112
113        let mut parts = vec![];
114
115        // The key map order in macOS is: ⌃⌥⇧⌘
116        // And in Windows is: Ctrl+Alt+Shift+Win
117
118        if key.modifiers.control {
119            #[cfg(target_os = "macos")]
120            parts.push("⌃");
121
122            #[cfg(not(target_os = "macos"))]
123            parts.push("Ctrl");
124        }
125
126        if key.modifiers.alt {
127            #[cfg(target_os = "macos")]
128            parts.push("⌥");
129
130            #[cfg(not(target_os = "macos"))]
131            parts.push("Alt");
132        }
133
134        if key.modifiers.shift {
135            #[cfg(target_os = "macos")]
136            parts.push("⇧");
137
138            #[cfg(not(target_os = "macos"))]
139            parts.push("Shift");
140        }
141
142        if key.modifiers.platform {
143            #[cfg(target_os = "macos")]
144            parts.push("⌘");
145
146            #[cfg(not(target_os = "macos"))]
147            parts.push("Win");
148        }
149
150        let mut keys = String::new();
151        let key_str = key.key.as_str();
152        match key_str {
153            #[cfg(target_os = "macos")]
154            "ctrl" => keys.push('⌃'),
155            #[cfg(not(target_os = "macos"))]
156            "ctrl" => keys.push_str("Ctrl"),
157            #[cfg(target_os = "macos")]
158            "alt" => keys.push('⌥'),
159            #[cfg(not(target_os = "macos"))]
160            "alt" => keys.push_str("Alt"),
161            #[cfg(target_os = "macos")]
162            "shift" => keys.push('⇧'),
163            #[cfg(not(target_os = "macos"))]
164            "shift" => keys.push_str("Shift"),
165            #[cfg(target_os = "macos")]
166            "cmd" => keys.push('⌘'),
167            #[cfg(not(target_os = "macos"))]
168            "cmd" => keys.push_str("Win"),
169            #[cfg(target_os = "macos")]
170            "space" => keys.push_str("Space"),
171            #[cfg(target_os = "macos")]
172            "backspace" => keys.push('⌫'),
173            #[cfg(not(target_os = "macos"))]
174            "backspace" => keys.push_str("Backspace"),
175            #[cfg(target_os = "macos")]
176            "delete" => keys.push('⌫'),
177            #[cfg(not(target_os = "macos"))]
178            "delete" => keys.push_str("Delete"),
179            #[cfg(target_os = "macos")]
180            "escape" => keys.push('⎋'),
181            #[cfg(not(target_os = "macos"))]
182            "escape" => keys.push_str("Esc"),
183            #[cfg(target_os = "macos")]
184            "enter" => keys.push('⏎'),
185            #[cfg(not(target_os = "macos"))]
186            "enter" => keys.push_str("Enter"),
187            "pagedown" => keys.push_str("Page Down"),
188            "pageup" => keys.push_str("Page Up"),
189            #[cfg(target_os = "macos")]
190            "left" => keys.push('←'),
191            #[cfg(not(target_os = "macos"))]
192            "left" => keys.push_str("Left"),
193            #[cfg(target_os = "macos")]
194            "right" => keys.push('→'),
195            #[cfg(not(target_os = "macos"))]
196            "right" => keys.push_str("Right"),
197            #[cfg(target_os = "macos")]
198            "up" => keys.push('↑'),
199            #[cfg(not(target_os = "macos"))]
200            "up" => keys.push_str("Up"),
201            #[cfg(target_os = "macos")]
202            "down" => keys.push('↓'),
203            #[cfg(not(target_os = "macos"))]
204            "down" => keys.push_str("Down"),
205            _ => {
206                if key_str.len() == 1 {
207                    keys.push_str(&key_str.to_uppercase());
208                } else {
209                    let mut chars = key_str.chars();
210                    if let Some(first_char) = chars.next() {
211                        keys.push_str(&format!(
212                            "{}{}",
213                            first_char.to_uppercase(),
214                            chars.collect::<String>()
215                        ));
216                    } else {
217                        keys.push_str(&key_str);
218                    }
219                }
220            }
221        }
222
223        parts.push(&keys);
224        parts.join(SEPARATOR)
225    }
226}
227
228impl Styled for Kbd {
229    fn style(&mut self) -> &mut StyleRefinement {
230        &mut self.style
231    }
232}
233
234impl RenderOnce for Kbd {
235    fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
236        if !self.appearance {
237            return Self::format(&self.stroke).into_any_element();
238        }
239
240        div()
241            // Lets a test ask whether a given shortcut hint was painted this
242            // frame; a no-op outside test-support builds.
243            .debug_selector(|| format!("kbd:{}", self.stroke.unparse(cx.platform_info().os)))
244            .text_color(cx.theme().muted_foreground)
245            .bg(cx.theme().tokens.muted)
246            .when(self.outline, |this| {
247                this.border_1()
248                    .border_color(cx.theme().border)
249                    .bg(cx.theme().tokens.background)
250            })
251            .py_0p5()
252            .px_1()
253            .min_w_5()
254            .text_center()
255            .rounded(cx.theme().radius.half())
256            .line_height(relative(1.))
257            .text_xs()
258            .whitespace_normal()
259            .flex_shrink_0()
260            .refine_style(&self.style)
261            .child(Self::format(&self.stroke))
262            .test_text(Self::format(&self.stroke))
263            .into_any_element()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    #[test]
270    fn test_format() {
271        use super::Kbd;
272        use gpui::Keystroke;
273
274        if cfg!(target_os = "macos") {
275            assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
276            assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
277            assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
278            assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
279            assert_eq!(Kbd::format(&Keystroke::parse("cmd-f12").unwrap()), "⌘F12");
280            assert_eq!(
281                Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
282                "⇧Page Down"
283            );
284            assert_eq!(
285                Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
286                "⇧Page Up"
287            );
288            assert_eq!(
289                Kbd::format(&Keystroke::parse("shift-space").unwrap()),
290                "⇧Space"
291            );
292            assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
293            assert_eq!(
294                Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
295                "⌥⌘⌫"
296            );
297            assert_eq!(
298                Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
299                "⇧⌫"
300            );
301            assert_eq!(
302                Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
303                "⌃⇧⌘A"
304            );
305            assert_eq!(
306                Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
307                "⌃⌥⇧⌘A"
308            );
309        } else {
310            assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
311            assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
312            assert_eq!(
313                Kbd::format(&Keystroke::parse("shift-space").unwrap()),
314                "Shift+Space"
315            );
316            assert_eq!(
317                Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
318                "Ctrl+Alt+A"
319            );
320            assert_eq!(
321                Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
322                "Ctrl+Alt+Shift+A"
323            );
324            assert_eq!(
325                Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
326                "Ctrl+Alt+Shift+Win+A"
327            );
328            assert_eq!(
329                Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
330                "Ctrl+Shift+Backspace"
331            );
332            assert_eq!(
333                Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
334                "Alt+Delete"
335            );
336            assert_eq!(
337                Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
338                "Alt+Tab"
339            );
340        }
341    }
342}