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