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