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