keybinding.rs

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