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