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