Skip to main content

rgpui_component/
kbd.rs

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