keybinding.rs

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