gpui_component/
kbd.rs

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