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
382 let delimiter = match (platform_style, vim_mode) {
383 (PlatformStyle::Mac, false) => '-',
384 (PlatformStyle::Linux | PlatformStyle::Windows, false) => '-',
385 (_, true) => '-',
386 };
387
388 if keystroke.modifiers.function {
389 match vim_mode {
390 false => text.push_str("Fn"),
391 true => text.push_str("fn"),
392 }
393
394 text.push(delimiter);
395 }
396
397 if keystroke.modifiers.control {
398 match (platform_style, vim_mode) {
399 (PlatformStyle::Mac, false) => text.push_str("Control"),
400 (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
401 (_, true) => text.push_str("ctrl"),
402 }
403
404 text.push(delimiter);
405 }
406
407 if keystroke.modifiers.platform {
408 match (platform_style, vim_mode) {
409 (PlatformStyle::Mac, false) => text.push_str("Command"),
410 (PlatformStyle::Mac, true) => text.push_str("cmd"),
411 (PlatformStyle::Linux, false) => text.push_str("Super"),
412 (PlatformStyle::Linux, true) => text.push_str("super"),
413 (PlatformStyle::Windows, false) => text.push_str("Win"),
414 (PlatformStyle::Windows, true) => text.push_str("win"),
415 }
416
417 text.push(delimiter);
418 }
419
420 if keystroke.modifiers.alt {
421 match (platform_style, vim_mode) {
422 (PlatformStyle::Mac, false) => text.push_str("Option"),
423 (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
424 (_, true) => text.push_str("alt"),
425 }
426
427 text.push(delimiter);
428 }
429
430 if keystroke.modifiers.shift {
431 match (platform_style, vim_mode) {
432 (_, false) => text.push_str("Shift"),
433 (_, true) => text.push_str("shift"),
434 }
435 text.push(delimiter);
436 }
437
438 if vim_mode {
439 text.push_str(&keystroke.key)
440 } else {
441 let key = match keystroke.key.as_str() {
442 "pageup" => "PageUp",
443 "pagedown" => "PageDown",
444 key => &util::capitalize(key),
445 };
446 text.push_str(key);
447 }
448
449 text
450}
451
452impl Component for KeyBinding {
453 fn scope() -> ComponentScope {
454 ComponentScope::Typography
455 }
456
457 fn name() -> &'static str {
458 "KeyBinding"
459 }
460
461 fn description() -> Option<&'static str> {
462 Some(
463 "A component that displays a key binding, supporting different platform styles and vim mode.",
464 )
465 }
466
467 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
468 Some(
469 v_flex()
470 .gap_6()
471 .children(vec![
472 example_group_with_title(
473 "Basic Usage",
474 vec![
475 single_example(
476 "Default",
477 KeyBinding::new(
478 gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
479 cx,
480 )
481 .into_any_element(),
482 ),
483 single_example(
484 "Mac Style",
485 KeyBinding::new(
486 gpui::KeyBinding::new("cmd-s", gpui::NoAction, None),
487 cx,
488 )
489 .platform_style(PlatformStyle::Mac)
490 .into_any_element(),
491 ),
492 single_example(
493 "Windows Style",
494 KeyBinding::new(
495 gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
496 cx,
497 )
498 .platform_style(PlatformStyle::Windows)
499 .into_any_element(),
500 ),
501 ],
502 ),
503 example_group_with_title(
504 "Vim Mode",
505 vec![single_example(
506 "Vim Mode Enabled",
507 KeyBinding::new(gpui::KeyBinding::new("dd", gpui::NoAction, None), cx)
508 .vim_mode(true)
509 .into_any_element(),
510 )],
511 ),
512 example_group_with_title(
513 "Complex Bindings",
514 vec![
515 single_example(
516 "Multiple Keys",
517 KeyBinding::new(
518 gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None),
519 cx,
520 )
521 .into_any_element(),
522 ),
523 single_example(
524 "With Shift",
525 KeyBinding::new(
526 gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None),
527 cx,
528 )
529 .into_any_element(),
530 ),
531 ],
532 ),
533 ])
534 .into_any_element(),
535 )
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn test_text_for_keystroke() {
545 assert_eq!(
546 keystroke_text(
547 &Keystroke::parse("cmd-c").unwrap(),
548 PlatformStyle::Mac,
549 false
550 ),
551 "Command-C".to_string()
552 );
553 assert_eq!(
554 keystroke_text(
555 &Keystroke::parse("cmd-c").unwrap(),
556 PlatformStyle::Linux,
557 false
558 ),
559 "Super-C".to_string()
560 );
561 assert_eq!(
562 keystroke_text(
563 &Keystroke::parse("cmd-c").unwrap(),
564 PlatformStyle::Windows,
565 false
566 ),
567 "Win-C".to_string()
568 );
569
570 assert_eq!(
571 keystroke_text(
572 &Keystroke::parse("ctrl-alt-delete").unwrap(),
573 PlatformStyle::Mac,
574 false
575 ),
576 "Control-Option-Delete".to_string()
577 );
578 assert_eq!(
579 keystroke_text(
580 &Keystroke::parse("ctrl-alt-delete").unwrap(),
581 PlatformStyle::Linux,
582 false
583 ),
584 "Ctrl-Alt-Delete".to_string()
585 );
586 assert_eq!(
587 keystroke_text(
588 &Keystroke::parse("ctrl-alt-delete").unwrap(),
589 PlatformStyle::Windows,
590 false
591 ),
592 "Ctrl-Alt-Delete".to_string()
593 );
594
595 assert_eq!(
596 keystroke_text(
597 &Keystroke::parse("shift-pageup").unwrap(),
598 PlatformStyle::Mac,
599 false
600 ),
601 "Shift-PageUp".to_string()
602 );
603 assert_eq!(
604 keystroke_text(
605 &Keystroke::parse("shift-pageup").unwrap(),
606 PlatformStyle::Linux,
607 false,
608 ),
609 "Shift-PageUp".to_string()
610 );
611 assert_eq!(
612 keystroke_text(
613 &Keystroke::parse("shift-pageup").unwrap(),
614 PlatformStyle::Windows,
615 false
616 ),
617 "Shift-PageUp".to_string()
618 );
619 }
620}