gpui_component/
kbd.rs

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