keybinding.rs

  1use crate::PlatformStyle;
  2use crate::{Icon, IconName, IconSize, h_flex, prelude::*};
  3use gpui::{
  4    Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke,
  5    Modifiers, Window, relative,
  6};
  7use itertools::Itertools;
  8
  9#[derive(Debug, IntoElement, Clone, RegisterComponent)]
 10pub struct KeyBinding {
 11    /// A keybinding consists of a set of keystrokes,
 12    /// where each keystroke is a key and a set of modifier keys.
 13    /// More than one keystroke produces a chord.
 14    ///
 15    /// This should always contain at least one keystroke.
 16    pub keystrokes: Vec<KeybindingKeystroke>,
 17
 18    /// The [`PlatformStyle`] to use when displaying this keybinding.
 19    platform_style: PlatformStyle,
 20    size: Option<AbsoluteLength>,
 21
 22    /// Determines whether the keybinding is meant for vim mode.
 23    vim_mode: bool,
 24
 25    /// Indicates whether the keybinding is currently disabled.
 26    disabled: bool,
 27}
 28
 29struct VimStyle(bool);
 30impl Global for VimStyle {}
 31
 32impl KeyBinding {
 33    /// Returns the highest precedence keybinding for an action. This is the last binding added to
 34    /// the keymap. User bindings are added after built-in bindings so that they take precedence.
 35    pub fn for_action(action: &dyn Action, window: &mut Window, cx: &App) -> Option<Self> {
 36        if let Some(focused) = window.focused(cx) {
 37            return Self::for_action_in(action, &focused, window, cx);
 38        }
 39        let key_binding = window.highest_precedence_binding_for_action(action)?;
 40        Some(Self::new_from_gpui(key_binding, cx))
 41    }
 42
 43    /// Like `for_action`, but lets you specify the context from which keybindings are matched.
 44    pub fn for_action_in(
 45        action: &dyn Action,
 46        focus: &FocusHandle,
 47        window: &Window,
 48        cx: &App,
 49    ) -> Option<Self> {
 50        let key_binding = window.highest_precedence_binding_for_action_in(action, focus)?;
 51        Some(Self::new_from_gpui(key_binding, cx))
 52    }
 53
 54    pub fn set_vim_mode(cx: &mut App, enabled: bool) {
 55        cx.set_global(VimStyle(enabled));
 56    }
 57
 58    fn is_vim_mode(cx: &App) -> bool {
 59        cx.try_global::<VimStyle>().is_some_and(|g| g.0)
 60    }
 61
 62    pub fn new(keystrokes: Vec<KeybindingKeystroke>, cx: &App) -> Self {
 63        Self {
 64            keystrokes,
 65            platform_style: PlatformStyle::platform(),
 66            size: None,
 67            vim_mode: KeyBinding::is_vim_mode(cx),
 68            disabled: false,
 69        }
 70    }
 71
 72    pub fn new_from_gpui(key_binding: gpui::KeyBinding, cx: &App) -> Self {
 73        Self::new(key_binding.keystrokes().to_vec(), cx)
 74    }
 75
 76    /// Sets the [`PlatformStyle`] for this [`KeyBinding`].
 77    pub fn platform_style(mut self, platform_style: PlatformStyle) -> Self {
 78        self.platform_style = platform_style;
 79        self
 80    }
 81
 82    /// Sets the size for this [`KeyBinding`].
 83    pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
 84        self.size = Some(size.into());
 85        self
 86    }
 87
 88    /// Sets whether this keybinding is currently disabled.
 89    /// Disabled keybinds will be rendered in a dimmed state.
 90    pub fn disabled(mut self, disabled: bool) -> Self {
 91        self.disabled = disabled;
 92        self
 93    }
 94
 95    pub fn vim_mode(mut self, enabled: bool) -> Self {
 96        self.vim_mode = enabled;
 97        self
 98    }
 99}
100
101fn render_key(
102    key: &str,
103    color: Option<Color>,
104    platform_style: PlatformStyle,
105    size: impl Into<Option<AbsoluteLength>>,
106) -> AnyElement {
107    let key_icon = icon_for_key(key, platform_style);
108    match key_icon {
109        Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
110        None => {
111            let key = util::capitalize(key);
112            Key::new(&key, color).size(size).into_any_element()
113        }
114    }
115}
116
117impl RenderOnce for KeyBinding {
118    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
119        let color = self.disabled.then_some(Color::Disabled);
120
121        h_flex()
122            .debug_selector(|| {
123                format!(
124                    "KEY_BINDING-{}",
125                    self.keystrokes
126                        .iter()
127                        .map(|k| k.display_key.to_string())
128                        .collect::<Vec<_>>()
129                        .join(" ")
130                )
131            })
132            .gap(DynamicSpacing::Base04.rems(cx))
133            .flex_none()
134            .children(self.keystrokes.iter().map(|keystroke| {
135                h_flex()
136                    .flex_none()
137                    .py_0p5()
138                    .rounded_xs()
139                    .text_color(cx.theme().colors().text_muted)
140                    .children(render_keybinding_keystroke(
141                        keystroke,
142                        color,
143                        self.size,
144                        self.platform_style,
145                        self.vim_mode,
146                    ))
147            }))
148    }
149}
150
151pub fn render_keybinding_keystroke(
152    keystroke: &KeybindingKeystroke,
153    color: Option<Color>,
154    size: impl Into<Option<AbsoluteLength>>,
155    platform_style: PlatformStyle,
156    vim_mode: bool,
157) -> Vec<AnyElement> {
158    let use_text = vim_mode
159        || matches!(
160            platform_style,
161            PlatformStyle::Linux | PlatformStyle::Windows
162        );
163    let size = size.into();
164
165    if use_text {
166        let element = Key::new(
167            keystroke_text(
168                &keystroke.display_modifiers,
169                &keystroke.display_key,
170                platform_style,
171                vim_mode,
172            ),
173            color,
174        )
175        .size(size)
176        .into_any_element();
177        vec![element]
178    } else {
179        let mut elements = Vec::new();
180        elements.extend(render_modifiers(
181            &keystroke.display_modifiers,
182            platform_style,
183            color,
184            size,
185            true,
186        ));
187        elements.push(render_key(
188            &keystroke.display_key,
189            color,
190            platform_style,
191            size,
192        ));
193        elements
194    }
195}
196
197fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option<IconName> {
198    match key {
199        "left" => Some(IconName::ArrowLeft),
200        "right" => Some(IconName::ArrowRight),
201        "up" => Some(IconName::ArrowUp),
202        "down" => Some(IconName::ArrowDown),
203        "backspace" => Some(IconName::Backspace),
204        "delete" => Some(IconName::Backspace),
205        "return" => Some(IconName::Return),
206        "enter" => Some(IconName::Return),
207        "tab" => Some(IconName::Tab),
208        "space" => Some(IconName::Space),
209        "escape" => Some(IconName::Escape),
210        "pagedown" => Some(IconName::PageDown),
211        "pageup" => Some(IconName::PageUp),
212        "shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
213        "control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
214        "platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
215        "function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
216        "alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
217        _ => None,
218    }
219}
220
221pub fn render_modifiers(
222    modifiers: &Modifiers,
223    platform_style: PlatformStyle,
224    color: Option<Color>,
225    size: Option<AbsoluteLength>,
226    trailing_separator: bool,
227) -> impl Iterator<Item = AnyElement> {
228    #[derive(Clone)]
229    enum KeyOrIcon {
230        Key(&'static str),
231        Plus,
232        Icon(IconName),
233    }
234
235    struct Modifier {
236        enabled: bool,
237        mac: KeyOrIcon,
238        linux: KeyOrIcon,
239        windows: KeyOrIcon,
240    }
241
242    let table = {
243        use KeyOrIcon::*;
244
245        [
246            Modifier {
247                enabled: modifiers.function,
248                mac: Icon(IconName::Control),
249                linux: Key("Fn"),
250                windows: Key("Fn"),
251            },
252            Modifier {
253                enabled: modifiers.control,
254                mac: Icon(IconName::Control),
255                linux: Key("Ctrl"),
256                windows: Key("Ctrl"),
257            },
258            Modifier {
259                enabled: modifiers.alt,
260                mac: Icon(IconName::Option),
261                linux: Key("Alt"),
262                windows: Key("Alt"),
263            },
264            Modifier {
265                enabled: modifiers.platform,
266                mac: Icon(IconName::Command),
267                linux: Key("Super"),
268                windows: Key("Win"),
269            },
270            Modifier {
271                enabled: modifiers.shift,
272                mac: Icon(IconName::Shift),
273                linux: Key("Shift"),
274                windows: Key("Shift"),
275            },
276        ]
277    };
278
279    let filtered = table
280        .into_iter()
281        .filter(|modifier| modifier.enabled)
282        .collect::<Vec<_>>();
283
284    let platform_keys = filtered
285        .into_iter()
286        .map(move |modifier| match platform_style {
287            PlatformStyle::Mac => Some(modifier.mac),
288            PlatformStyle::Linux => Some(modifier.linux),
289            PlatformStyle::Windows => Some(modifier.windows),
290        });
291
292    let separator = match platform_style {
293        PlatformStyle::Mac => None,
294        PlatformStyle::Linux => Some(KeyOrIcon::Plus),
295        PlatformStyle::Windows => Some(KeyOrIcon::Plus),
296    };
297
298    let platform_keys = itertools::intersperse(platform_keys, separator.clone());
299
300    platform_keys
301        .chain(if modifiers.modified() && trailing_separator {
302            Some(separator)
303        } else {
304            None
305        })
306        .flatten()
307        .map(move |key_or_icon| match key_or_icon {
308            KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(),
309            KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
310            KeyOrIcon::Plus => "+".into_any_element(),
311        })
312}
313
314#[derive(IntoElement)]
315pub struct Key {
316    key: SharedString,
317    color: Option<Color>,
318    size: Option<AbsoluteLength>,
319}
320
321impl RenderOnce for Key {
322    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
323        let single_char = self.key.len() == 1;
324        let size = self
325            .size
326            .unwrap_or_else(|| TextSize::default().rems(cx).into());
327
328        div()
329            .py_0()
330            .map(|this| {
331                if single_char {
332                    this.w(size).flex().flex_none().justify_center()
333                } else {
334                    this.px_0p5()
335                }
336            })
337            .h(size)
338            .text_size(size)
339            .line_height(relative(1.))
340            .text_color(self.color.unwrap_or(Color::Muted).color(cx))
341            .child(self.key)
342    }
343}
344
345impl Key {
346    pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
347        Self {
348            key: key.into(),
349            color,
350            size: None,
351        }
352    }
353
354    pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
355        self.size = size.into();
356        self
357    }
358}
359
360#[derive(IntoElement)]
361pub struct KeyIcon {
362    icon: IconName,
363    color: Option<Color>,
364    size: Option<AbsoluteLength>,
365}
366
367impl RenderOnce for KeyIcon {
368    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
369        let size = self.size.unwrap_or(IconSize::Small.rems().into());
370
371        Icon::new(self.icon)
372            .size(IconSize::Custom(size.to_rems(window.rem_size())))
373            .color(self.color.unwrap_or(Color::Muted))
374    }
375}
376
377impl KeyIcon {
378    pub fn new(icon: IconName, color: Option<Color>) -> Self {
379        Self {
380            icon,
381            color,
382            size: None,
383        }
384    }
385
386    pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
387        self.size = size.into();
388        self
389    }
390}
391
392/// Returns a textual representation of the key binding for the given [`Action`].
393pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
394    let key_binding = window.highest_precedence_binding_for_action(action)?;
395    Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx))
396}
397
398pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
399    let platform_style = PlatformStyle::platform();
400    let vim_enabled = cx.try_global::<VimStyle>().is_some();
401    keystrokes
402        .iter()
403        .map(|keystroke| {
404            keystroke_text(
405                &keystroke.modifiers,
406                &keystroke.key,
407                platform_style,
408                vim_enabled,
409            )
410        })
411        .join(" ")
412}
413
414pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String {
415    let platform_style = PlatformStyle::platform();
416    let vim_enabled = cx.try_global::<VimStyle>().is_some();
417    keystrokes
418        .iter()
419        .map(|keystroke| {
420            keystroke_text(
421                &keystroke.display_modifiers,
422                &keystroke.display_key,
423                platform_style,
424                vim_enabled,
425            )
426        })
427        .join(" ")
428}
429
430pub fn text_for_keystroke(modifiers: &Modifiers, key: &str, cx: &App) -> String {
431    let platform_style = PlatformStyle::platform();
432    let vim_enabled = cx.try_global::<VimStyle>().is_some();
433    keystroke_text(modifiers, key, platform_style, vim_enabled)
434}
435
436/// Returns a textual representation of the given [`Keystroke`].
437fn keystroke_text(
438    modifiers: &Modifiers,
439    key: &str,
440    platform_style: PlatformStyle,
441    vim_mode: bool,
442) -> String {
443    let mut text = String::new();
444    let delimiter = '-';
445
446    if modifiers.function {
447        match vim_mode {
448            false => text.push_str("Fn"),
449            true => text.push_str("fn"),
450        }
451
452        text.push(delimiter);
453    }
454
455    if modifiers.control {
456        match (platform_style, vim_mode) {
457            (PlatformStyle::Mac, false) => text.push_str("Control"),
458            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
459            (_, true) => text.push_str("ctrl"),
460        }
461
462        text.push(delimiter);
463    }
464
465    if modifiers.platform {
466        match (platform_style, vim_mode) {
467            (PlatformStyle::Mac, false) => text.push_str("Command"),
468            (PlatformStyle::Mac, true) => text.push_str("cmd"),
469            (PlatformStyle::Linux, false) => text.push_str("Super"),
470            (PlatformStyle::Linux, true) => text.push_str("super"),
471            (PlatformStyle::Windows, false) => text.push_str("Win"),
472            (PlatformStyle::Windows, true) => text.push_str("win"),
473        }
474
475        text.push(delimiter);
476    }
477
478    if modifiers.alt {
479        match (platform_style, vim_mode) {
480            (PlatformStyle::Mac, false) => text.push_str("Option"),
481            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
482            (_, true) => text.push_str("alt"),
483        }
484
485        text.push(delimiter);
486    }
487
488    if modifiers.shift {
489        match (platform_style, vim_mode) {
490            (_, false) => text.push_str("Shift"),
491            (_, true) => text.push_str("shift"),
492        }
493        text.push(delimiter);
494    }
495
496    if vim_mode {
497        text.push_str(key)
498    } else {
499        let key = match key {
500            "pageup" => "PageUp",
501            "pagedown" => "PageDown",
502            key => &util::capitalize(key),
503        };
504        text.push_str(key);
505    }
506
507    text
508}
509
510impl Component for KeyBinding {
511    fn scope() -> ComponentScope {
512        ComponentScope::Typography
513    }
514
515    fn name() -> &'static str {
516        "KeyBinding"
517    }
518
519    fn description() -> Option<&'static str> {
520        Some(
521            "A component that displays a key binding, supporting different platform styles and vim mode.",
522        )
523    }
524
525    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
526        Some(
527            v_flex()
528                .gap_6()
529                .children(vec![
530                    example_group_with_title(
531                        "Basic Usage",
532                        vec![
533                            single_example(
534                                "Default",
535                                KeyBinding::new_from_gpui(
536                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
537                                    cx,
538                                )
539                                .into_any_element(),
540                            ),
541                            single_example(
542                                "Mac Style",
543                                KeyBinding::new_from_gpui(
544                                    gpui::KeyBinding::new("cmd-s", gpui::NoAction, None),
545                                    cx,
546                                )
547                                .platform_style(PlatformStyle::Mac)
548                                .into_any_element(),
549                            ),
550                            single_example(
551                                "Windows Style",
552                                KeyBinding::new_from_gpui(
553                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
554                                    cx,
555                                )
556                                .platform_style(PlatformStyle::Windows)
557                                .into_any_element(),
558                            ),
559                        ],
560                    ),
561                    example_group_with_title(
562                        "Vim Mode",
563                        vec![single_example(
564                            "Vim Mode Enabled",
565                            KeyBinding::new_from_gpui(
566                                gpui::KeyBinding::new("dd", gpui::NoAction, None),
567                                cx,
568                            )
569                            .vim_mode(true)
570                            .into_any_element(),
571                        )],
572                    ),
573                    example_group_with_title(
574                        "Complex Bindings",
575                        vec![
576                            single_example(
577                                "Multiple Keys",
578                                KeyBinding::new_from_gpui(
579                                    gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None),
580                                    cx,
581                                )
582                                .into_any_element(),
583                            ),
584                            single_example(
585                                "With Shift",
586                                KeyBinding::new_from_gpui(
587                                    gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None),
588                                    cx,
589                                )
590                                .into_any_element(),
591                            ),
592                        ],
593                    ),
594                ])
595                .into_any_element(),
596        )
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn test_text_for_keystroke() {
606        let keystroke = Keystroke::parse("cmd-c").unwrap();
607        assert_eq!(
608            keystroke_text(
609                &keystroke.modifiers,
610                &keystroke.key,
611                PlatformStyle::Mac,
612                false
613            ),
614            "Command-C".to_string()
615        );
616        assert_eq!(
617            keystroke_text(
618                &keystroke.modifiers,
619                &keystroke.key,
620                PlatformStyle::Linux,
621                false
622            ),
623            "Super-C".to_string()
624        );
625        assert_eq!(
626            keystroke_text(
627                &keystroke.modifiers,
628                &keystroke.key,
629                PlatformStyle::Windows,
630                false
631            ),
632            "Win-C".to_string()
633        );
634
635        let keystroke = Keystroke::parse("ctrl-alt-delete").unwrap();
636        assert_eq!(
637            keystroke_text(
638                &keystroke.modifiers,
639                &keystroke.key,
640                PlatformStyle::Mac,
641                false
642            ),
643            "Control-Option-Delete".to_string()
644        );
645        assert_eq!(
646            keystroke_text(
647                &keystroke.modifiers,
648                &keystroke.key,
649                PlatformStyle::Linux,
650                false
651            ),
652            "Ctrl-Alt-Delete".to_string()
653        );
654        assert_eq!(
655            keystroke_text(
656                &keystroke.modifiers,
657                &keystroke.key,
658                PlatformStyle::Windows,
659                false
660            ),
661            "Ctrl-Alt-Delete".to_string()
662        );
663
664        let keystroke = Keystroke::parse("shift-pageup").unwrap();
665        assert_eq!(
666            keystroke_text(
667                &keystroke.modifiers,
668                &keystroke.key,
669                PlatformStyle::Mac,
670                false
671            ),
672            "Shift-PageUp".to_string()
673        );
674        assert_eq!(
675            keystroke_text(
676                &keystroke.modifiers,
677                &keystroke.key,
678                PlatformStyle::Linux,
679                false,
680            ),
681            "Shift-PageUp".to_string()
682        );
683        assert_eq!(
684            keystroke_text(
685                &keystroke.modifiers,
686                &keystroke.key,
687                PlatformStyle::Windows,
688                false
689            ),
690            "Shift-PageUp".to_string()
691        );
692    }
693}