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