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