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