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