keybinding.rs

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