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