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: &mut 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.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_keystroke(
141                        &keystroke.modifiers,
142                        &keystroke.key,
143                        color,
144                        self.size,
145                        self.platform_style,
146                        self.vim_mode,
147                    ))
148            }))
149    }
150}
151
152pub fn render_keystroke(
153    modifiers: &Modifiers,
154    key: &str,
155    color: Option<Color>,
156    size: impl Into<Option<AbsoluteLength>>,
157    platform_style: PlatformStyle,
158    vim_mode: bool,
159) -> Vec<AnyElement> {
160    let use_text = vim_mode
161        || matches!(
162            platform_style,
163            PlatformStyle::Linux | PlatformStyle::Windows
164        );
165    let size = size.into();
166
167    if use_text {
168        let element = Key::new(
169            keystroke_text(modifiers, key, platform_style, vim_mode),
170            color,
171        )
172        .size(size)
173        .into_any_element();
174        vec![element]
175    } else {
176        let mut elements = Vec::new();
177        elements.extend(render_modifiers(
178            modifiers,
179            platform_style,
180            color,
181            size,
182            true,
183        ));
184        elements.push(render_key(key, color, platform_style, size));
185        elements
186    }
187}
188
189fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option<IconName> {
190    match key {
191        "left" => Some(IconName::ArrowLeft),
192        "right" => Some(IconName::ArrowRight),
193        "up" => Some(IconName::ArrowUp),
194        "down" => Some(IconName::ArrowDown),
195        "backspace" => Some(IconName::Backspace),
196        "delete" => Some(IconName::Delete),
197        "return" => Some(IconName::Return),
198        "enter" => Some(IconName::Return),
199        "tab" => Some(IconName::Tab),
200        "space" => Some(IconName::Space),
201        "escape" => Some(IconName::Escape),
202        "pagedown" => Some(IconName::PageDown),
203        "pageup" => Some(IconName::PageUp),
204        "shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
205        "control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
206        "platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
207        "function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
208        "alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
209        _ => None,
210    }
211}
212
213pub fn render_modifiers(
214    modifiers: &Modifiers,
215    platform_style: PlatformStyle,
216    color: Option<Color>,
217    size: Option<AbsoluteLength>,
218    trailing_separator: bool,
219) -> impl Iterator<Item = AnyElement> {
220    #[derive(Clone)]
221    enum KeyOrIcon {
222        Key(&'static str),
223        Plus,
224        Icon(IconName),
225    }
226
227    struct Modifier {
228        enabled: bool,
229        mac: KeyOrIcon,
230        linux: KeyOrIcon,
231        windows: KeyOrIcon,
232    }
233
234    let table = {
235        use KeyOrIcon::*;
236
237        [
238            Modifier {
239                enabled: modifiers.function,
240                mac: Icon(IconName::Control),
241                linux: Key("Fn"),
242                windows: Key("Fn"),
243            },
244            Modifier {
245                enabled: modifiers.control,
246                mac: Icon(IconName::Control),
247                linux: Key("Ctrl"),
248                windows: Key("Ctrl"),
249            },
250            Modifier {
251                enabled: modifiers.alt,
252                mac: Icon(IconName::Option),
253                linux: Key("Alt"),
254                windows: Key("Alt"),
255            },
256            Modifier {
257                enabled: modifiers.platform,
258                mac: Icon(IconName::Command),
259                linux: Key("Super"),
260                windows: Key("Win"),
261            },
262            Modifier {
263                enabled: modifiers.shift,
264                mac: Icon(IconName::Shift),
265                linux: Key("Shift"),
266                windows: Key("Shift"),
267            },
268        ]
269    };
270
271    let filtered = table
272        .into_iter()
273        .filter(|modifier| modifier.enabled)
274        .collect::<Vec<_>>();
275
276    let platform_keys = filtered
277        .into_iter()
278        .map(move |modifier| match platform_style {
279            PlatformStyle::Mac => Some(modifier.mac),
280            PlatformStyle::Linux => Some(modifier.linux),
281            PlatformStyle::Windows => Some(modifier.windows),
282        });
283
284    let separator = match platform_style {
285        PlatformStyle::Mac => None,
286        PlatformStyle::Linux => Some(KeyOrIcon::Plus),
287        PlatformStyle::Windows => Some(KeyOrIcon::Plus),
288    };
289
290    let platform_keys = itertools::intersperse(platform_keys, separator.clone());
291
292    platform_keys
293        .chain(if modifiers.modified() && trailing_separator {
294            Some(separator)
295        } else {
296            None
297        })
298        .flatten()
299        .map(move |key_or_icon| match key_or_icon {
300            KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(),
301            KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
302            KeyOrIcon::Plus => "+".into_any_element(),
303        })
304}
305
306#[derive(IntoElement)]
307pub struct Key {
308    key: SharedString,
309    color: Option<Color>,
310    size: Option<AbsoluteLength>,
311}
312
313impl RenderOnce for Key {
314    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
315        let single_char = self.key.len() == 1;
316        let size = self
317            .size
318            .unwrap_or_else(|| TextSize::default().rems(cx).into());
319
320        div()
321            .py_0()
322            .map(|this| {
323                if single_char {
324                    this.w(size).flex().flex_none().justify_center()
325                } else {
326                    this.px_0p5()
327                }
328            })
329            .h(size)
330            .text_size(size)
331            .line_height(relative(1.))
332            .text_color(self.color.unwrap_or(Color::Muted).color(cx))
333            .child(self.key.clone())
334    }
335}
336
337impl Key {
338    pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
339        Self {
340            key: key.into(),
341            color,
342            size: None,
343        }
344    }
345
346    pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
347        self.size = size.into();
348        self
349    }
350}
351
352#[derive(IntoElement)]
353pub struct KeyIcon {
354    icon: IconName,
355    color: Option<Color>,
356    size: Option<AbsoluteLength>,
357}
358
359impl RenderOnce for KeyIcon {
360    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
361        let size = self.size.unwrap_or(IconSize::Small.rems().into());
362
363        Icon::new(self.icon)
364            .size(IconSize::Custom(size.to_rems(window.rem_size())))
365            .color(self.color.unwrap_or(Color::Muted))
366    }
367}
368
369impl KeyIcon {
370    pub fn new(icon: IconName, color: Option<Color>) -> Self {
371        Self {
372            icon,
373            color,
374            size: None,
375        }
376    }
377
378    pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
379        self.size = size.into();
380        self
381    }
382}
383
384/// Returns a textual representation of the key binding for the given [`Action`].
385pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
386    let key_binding = window.highest_precedence_binding_for_action(action)?;
387    Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx))
388}
389
390pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String {
391    let platform_style = PlatformStyle::platform();
392    let vim_enabled = cx.try_global::<VimStyle>().is_some();
393    keystrokes
394        .iter()
395        .map(|keystroke| {
396            keystroke_text(
397                &keystroke.modifiers,
398                &keystroke.key,
399                platform_style,
400                vim_enabled,
401            )
402        })
403        .join(" ")
404}
405
406pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
407    let platform_style = PlatformStyle::platform();
408    let vim_enabled = cx.try_global::<VimStyle>().is_some();
409    keystrokes
410        .iter()
411        .map(|keystroke| {
412            keystroke_text(
413                &keystroke.modifiers,
414                &keystroke.key,
415                platform_style,
416                vim_enabled,
417            )
418        })
419        .join(" ")
420}
421
422pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String {
423    let platform_style = PlatformStyle::platform();
424    let vim_enabled = cx.try_global::<VimStyle>().is_some();
425    keystroke_text(
426        &keystroke.modifiers,
427        &keystroke.key,
428        platform_style,
429        vim_enabled,
430    )
431}
432
433/// Returns a textual representation of the given [`Keystroke`].
434fn keystroke_text(
435    modifiers: &Modifiers,
436    key: &str,
437    platform_style: PlatformStyle,
438    vim_mode: bool,
439) -> String {
440    let mut text = String::new();
441    let delimiter = '-';
442
443    if modifiers.function {
444        match vim_mode {
445            false => text.push_str("Fn"),
446            true => text.push_str("fn"),
447        }
448
449        text.push(delimiter);
450    }
451
452    if modifiers.control {
453        match (platform_style, vim_mode) {
454            (PlatformStyle::Mac, false) => text.push_str("Control"),
455            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
456            (_, true) => text.push_str("ctrl"),
457        }
458
459        text.push(delimiter);
460    }
461
462    if modifiers.platform {
463        match (platform_style, vim_mode) {
464            (PlatformStyle::Mac, false) => text.push_str("Command"),
465            (PlatformStyle::Mac, true) => text.push_str("cmd"),
466            (PlatformStyle::Linux, false) => text.push_str("Super"),
467            (PlatformStyle::Linux, true) => text.push_str("super"),
468            (PlatformStyle::Windows, false) => text.push_str("Win"),
469            (PlatformStyle::Windows, true) => text.push_str("win"),
470        }
471
472        text.push(delimiter);
473    }
474
475    if modifiers.alt {
476        match (platform_style, vim_mode) {
477            (PlatformStyle::Mac, false) => text.push_str("Option"),
478            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
479            (_, true) => text.push_str("alt"),
480        }
481
482        text.push(delimiter);
483    }
484
485    if modifiers.shift {
486        match (platform_style, vim_mode) {
487            (_, false) => text.push_str("Shift"),
488            (_, true) => text.push_str("shift"),
489        }
490        text.push(delimiter);
491    }
492
493    if vim_mode {
494        text.push_str(key)
495    } else {
496        let key = match key {
497            "pageup" => "PageUp",
498            "pagedown" => "PageDown",
499            key => &util::capitalize(key),
500        };
501        text.push_str(key);
502    }
503
504    text
505}
506
507impl Component for KeyBinding {
508    fn scope() -> ComponentScope {
509        ComponentScope::Typography
510    }
511
512    fn name() -> &'static str {
513        "KeyBinding"
514    }
515
516    fn description() -> Option<&'static str> {
517        Some(
518            "A component that displays a key binding, supporting different platform styles and vim mode.",
519        )
520    }
521
522    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
523        Some(
524            v_flex()
525                .gap_6()
526                .children(vec![
527                    example_group_with_title(
528                        "Basic Usage",
529                        vec![
530                            single_example(
531                                "Default",
532                                KeyBinding::new_from_gpui(
533                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
534                                    cx,
535                                )
536                                .into_any_element(),
537                            ),
538                            single_example(
539                                "Mac Style",
540                                KeyBinding::new_from_gpui(
541                                    gpui::KeyBinding::new("cmd-s", gpui::NoAction, None),
542                                    cx,
543                                )
544                                .platform_style(PlatformStyle::Mac)
545                                .into_any_element(),
546                            ),
547                            single_example(
548                                "Windows Style",
549                                KeyBinding::new_from_gpui(
550                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
551                                    cx,
552                                )
553                                .platform_style(PlatformStyle::Windows)
554                                .into_any_element(),
555                            ),
556                        ],
557                    ),
558                    example_group_with_title(
559                        "Vim Mode",
560                        vec![single_example(
561                            "Vim Mode Enabled",
562                            KeyBinding::new_from_gpui(
563                                gpui::KeyBinding::new("dd", gpui::NoAction, None),
564                                cx,
565                            )
566                            .vim_mode(true)
567                            .into_any_element(),
568                        )],
569                    ),
570                    example_group_with_title(
571                        "Complex Bindings",
572                        vec![
573                            single_example(
574                                "Multiple Keys",
575                                KeyBinding::new_from_gpui(
576                                    gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None),
577                                    cx,
578                                )
579                                .into_any_element(),
580                            ),
581                            single_example(
582                                "With Shift",
583                                KeyBinding::new_from_gpui(
584                                    gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None),
585                                    cx,
586                                )
587                                .into_any_element(),
588                            ),
589                        ],
590                    ),
591                ])
592                .into_any_element(),
593        )
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_text_for_keystroke() {
603        assert_eq!(
604            keystroke_text(
605                &Keystroke::parse("cmd-c").unwrap(),
606                PlatformStyle::Mac,
607                false
608            ),
609            "Command-C".to_string()
610        );
611        assert_eq!(
612            keystroke_text(
613                &Keystroke::parse("cmd-c").unwrap(),
614                PlatformStyle::Linux,
615                false
616            ),
617            "Super-C".to_string()
618        );
619        assert_eq!(
620            keystroke_text(
621                &Keystroke::parse("cmd-c").unwrap(),
622                PlatformStyle::Windows,
623                false
624            ),
625            "Win-C".to_string()
626        );
627
628        assert_eq!(
629            keystroke_text(
630                &Keystroke::parse("ctrl-alt-delete").unwrap(),
631                PlatformStyle::Mac,
632                false
633            ),
634            "Control-Option-Delete".to_string()
635        );
636        assert_eq!(
637            keystroke_text(
638                &Keystroke::parse("ctrl-alt-delete").unwrap(),
639                PlatformStyle::Linux,
640                false
641            ),
642            "Ctrl-Alt-Delete".to_string()
643        );
644        assert_eq!(
645            keystroke_text(
646                &Keystroke::parse("ctrl-alt-delete").unwrap(),
647                PlatformStyle::Windows,
648                false
649            ),
650            "Ctrl-Alt-Delete".to_string()
651        );
652
653        assert_eq!(
654            keystroke_text(
655                &Keystroke::parse("shift-pageup").unwrap(),
656                PlatformStyle::Mac,
657                false
658            ),
659            "Shift-PageUp".to_string()
660        );
661        assert_eq!(
662            keystroke_text(
663                &Keystroke::parse("shift-pageup").unwrap(),
664                PlatformStyle::Linux,
665                false,
666            ),
667            "Shift-PageUp".to_string()
668        );
669        assert_eq!(
670            keystroke_text(
671                &Keystroke::parse("shift-pageup").unwrap(),
672                PlatformStyle::Windows,
673                false
674            ),
675            "Shift-PageUp".to_string()
676        );
677    }
678}