keybinding.rs

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