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