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