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