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    let delimiter = '-';
382
383    if keystroke.modifiers.function {
384        match vim_mode {
385            false => text.push_str("Fn"),
386            true => text.push_str("fn"),
387        }
388
389        text.push(delimiter);
390    }
391
392    if keystroke.modifiers.control {
393        match (platform_style, vim_mode) {
394            (PlatformStyle::Mac, false) => text.push_str("Control"),
395            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
396            (_, true) => text.push_str("ctrl"),
397        }
398
399        text.push(delimiter);
400    }
401
402    if keystroke.modifiers.platform {
403        match (platform_style, vim_mode) {
404            (PlatformStyle::Mac, false) => text.push_str("Command"),
405            (PlatformStyle::Mac, true) => text.push_str("cmd"),
406            (PlatformStyle::Linux, false) => text.push_str("Super"),
407            (PlatformStyle::Linux, true) => text.push_str("super"),
408            (PlatformStyle::Windows, false) => text.push_str("Win"),
409            (PlatformStyle::Windows, true) => text.push_str("win"),
410        }
411
412        text.push(delimiter);
413    }
414
415    if keystroke.modifiers.alt {
416        match (platform_style, vim_mode) {
417            (PlatformStyle::Mac, false) => text.push_str("Option"),
418            (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
419            (_, true) => text.push_str("alt"),
420        }
421
422        text.push(delimiter);
423    }
424
425    if keystroke.modifiers.shift {
426        match (platform_style, vim_mode) {
427            (_, false) => text.push_str("Shift"),
428            (_, true) => text.push_str("shift"),
429        }
430        text.push(delimiter);
431    }
432
433    if vim_mode {
434        text.push_str(&keystroke.key)
435    } else {
436        let key = match keystroke.key.as_str() {
437            "pageup" => "PageUp",
438            "pagedown" => "PageDown",
439            key => &util::capitalize(key),
440        };
441        text.push_str(key);
442    }
443
444    text
445}
446
447impl Component for KeyBinding {
448    fn scope() -> ComponentScope {
449        ComponentScope::Typography
450    }
451
452    fn name() -> &'static str {
453        "KeyBinding"
454    }
455
456    fn description() -> Option<&'static str> {
457        Some(
458            "A component that displays a key binding, supporting different platform styles and vim mode.",
459        )
460    }
461
462    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
463        Some(
464            v_flex()
465                .gap_6()
466                .children(vec![
467                    example_group_with_title(
468                        "Basic Usage",
469                        vec![
470                            single_example(
471                                "Default",
472                                KeyBinding::new(
473                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
474                                    cx,
475                                )
476                                .into_any_element(),
477                            ),
478                            single_example(
479                                "Mac Style",
480                                KeyBinding::new(
481                                    gpui::KeyBinding::new("cmd-s", gpui::NoAction, None),
482                                    cx,
483                                )
484                                .platform_style(PlatformStyle::Mac)
485                                .into_any_element(),
486                            ),
487                            single_example(
488                                "Windows Style",
489                                KeyBinding::new(
490                                    gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
491                                    cx,
492                                )
493                                .platform_style(PlatformStyle::Windows)
494                                .into_any_element(),
495                            ),
496                        ],
497                    ),
498                    example_group_with_title(
499                        "Vim Mode",
500                        vec![single_example(
501                            "Vim Mode Enabled",
502                            KeyBinding::new(gpui::KeyBinding::new("dd", gpui::NoAction, None), cx)
503                                .vim_mode(true)
504                                .into_any_element(),
505                        )],
506                    ),
507                    example_group_with_title(
508                        "Complex Bindings",
509                        vec![
510                            single_example(
511                                "Multiple Keys",
512                                KeyBinding::new(
513                                    gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None),
514                                    cx,
515                                )
516                                .into_any_element(),
517                            ),
518                            single_example(
519                                "With Shift",
520                                KeyBinding::new(
521                                    gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None),
522                                    cx,
523                                )
524                                .into_any_element(),
525                            ),
526                        ],
527                    ),
528                ])
529                .into_any_element(),
530        )
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_text_for_keystroke() {
540        assert_eq!(
541            keystroke_text(
542                &Keystroke::parse("cmd-c").unwrap(),
543                PlatformStyle::Mac,
544                false
545            ),
546            "Command-C".to_string()
547        );
548        assert_eq!(
549            keystroke_text(
550                &Keystroke::parse("cmd-c").unwrap(),
551                PlatformStyle::Linux,
552                false
553            ),
554            "Super-C".to_string()
555        );
556        assert_eq!(
557            keystroke_text(
558                &Keystroke::parse("cmd-c").unwrap(),
559                PlatformStyle::Windows,
560                false
561            ),
562            "Win-C".to_string()
563        );
564
565        assert_eq!(
566            keystroke_text(
567                &Keystroke::parse("ctrl-alt-delete").unwrap(),
568                PlatformStyle::Mac,
569                false
570            ),
571            "Control-Option-Delete".to_string()
572        );
573        assert_eq!(
574            keystroke_text(
575                &Keystroke::parse("ctrl-alt-delete").unwrap(),
576                PlatformStyle::Linux,
577                false
578            ),
579            "Ctrl-Alt-Delete".to_string()
580        );
581        assert_eq!(
582            keystroke_text(
583                &Keystroke::parse("ctrl-alt-delete").unwrap(),
584                PlatformStyle::Windows,
585                false
586            ),
587            "Ctrl-Alt-Delete".to_string()
588        );
589
590        assert_eq!(
591            keystroke_text(
592                &Keystroke::parse("shift-pageup").unwrap(),
593                PlatformStyle::Mac,
594                false
595            ),
596            "Shift-PageUp".to_string()
597        );
598        assert_eq!(
599            keystroke_text(
600                &Keystroke::parse("shift-pageup").unwrap(),
601                PlatformStyle::Linux,
602                false,
603            ),
604            "Shift-PageUp".to_string()
605        );
606        assert_eq!(
607            keystroke_text(
608                &Keystroke::parse("shift-pageup").unwrap(),
609                PlatformStyle::Windows,
610                false
611            ),
612            "Shift-PageUp".to_string()
613        );
614    }
615}