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