1#![allow(missing_docs)]
2use crate::PlatformStyle;
3use crate::{h_flex, prelude::*, Icon, IconName, IconSize};
4use gpui::{
5 relative, Action, AnyElement, App, FocusHandle, Global, IntoElement, Keystroke, Modifiers,
6 Window,
7};
8use itertools::Itertools;
9
10#[derive(Debug, IntoElement, Clone)]
11pub struct KeyBinding {
12 /// A keybinding consists of a key and a set of modifier keys.
13 /// More then one keybinding produces a chord.
14 ///
15 /// This should always contain at least one element.
16 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
26struct VimStyle(bool);
27impl Global for VimStyle {}
28
29impl KeyBinding {
30 /// Returns the highest precedence keybinding for an action. This is the last binding added to
31 /// the keymap. User bindings are added after built-in bindings so that they take precedence.
32 pub fn for_action(action: &dyn Action, window: &mut Window, cx: &App) -> Option<Self> {
33 let key_binding = window
34 .bindings_for_action(action)
35 .into_iter()
36 .rev()
37 .next()?;
38 Some(Self::new(key_binding, cx))
39 }
40
41 /// Like `for_action`, but lets you specify the context from which keybindings are matched.
42 pub fn for_action_in(
43 action: &dyn Action,
44 focus: &FocusHandle,
45 window: &mut Window,
46 cx: &App,
47 ) -> Option<Self> {
48 let key_binding = window
49 .bindings_for_action_in(action, focus)
50 .into_iter()
51 .rev()
52 .next()?;
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 }
71 }
72
73 /// Sets the [`PlatformStyle`] for this [`KeyBinding`].
74 pub fn platform_style(mut self, platform_style: PlatformStyle) -> Self {
75 self.platform_style = platform_style;
76 self
77 }
78
79 /// Sets the size for this [`KeyBinding`].
80 pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
81 self.size = Some(size.into());
82 self
83 }
84
85 pub fn vim_mode(mut self, enabled: bool) -> Self {
86 self.vim_mode = enabled;
87 self
88 }
89
90 fn render_key(&self, keystroke: &Keystroke, color: Option<Color>) -> AnyElement {
91 let key_icon = icon_for_key(keystroke, self.platform_style);
92 match key_icon {
93 Some(icon) => KeyIcon::new(icon, color).size(self.size).into_any_element(),
94 None => {
95 let key = util::capitalize(&keystroke.key);
96 Key::new(&key, color).size(self.size).into_any_element()
97 }
98 }
99 }
100}
101
102impl RenderOnce for KeyBinding {
103 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
104 h_flex()
105 .debug_selector(|| {
106 format!(
107 "KEY_BINDING-{}",
108 self.key_binding
109 .keystrokes()
110 .iter()
111 .map(|k| k.key.to_string())
112 .collect::<Vec<_>>()
113 .join(" ")
114 )
115 })
116 .gap(DynamicSpacing::Base04.rems(cx))
117 .flex_none()
118 .children(self.key_binding.keystrokes().iter().map(|keystroke| {
119 h_flex()
120 .flex_none()
121 .py_0p5()
122 .rounded_sm()
123 .text_color(cx.theme().colors().text_muted)
124 .when(self.vim_mode, |el| {
125 el.child(
126 Key::new(
127 keystroke_text(&keystroke, self.platform_style, self.vim_mode),
128 None,
129 )
130 .size(self.size),
131 )
132 })
133 .when(!self.vim_mode, |el| {
134 el.children(render_modifiers(
135 &keystroke.modifiers,
136 self.platform_style,
137 None,
138 self.size,
139 true,
140 ))
141 .map(|el| el.child(self.render_key(&keystroke, None)))
142 })
143 }))
144 }
145}
146
147fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option<IconName> {
148 match keystroke.key.as_str() {
149 "left" => Some(IconName::ArrowLeft),
150 "right" => Some(IconName::ArrowRight),
151 "up" => Some(IconName::ArrowUp),
152 "down" => Some(IconName::ArrowDown),
153 "backspace" => Some(IconName::Backspace),
154 "delete" => Some(IconName::Delete),
155 "return" => Some(IconName::Return),
156 "enter" => Some(IconName::Return),
157 "tab" => Some(IconName::Tab),
158 "space" => Some(IconName::Space),
159 "escape" => Some(IconName::Escape),
160 "pagedown" => Some(IconName::PageDown),
161 "pageup" => Some(IconName::PageUp),
162 "shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
163 "control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
164 "platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
165 "function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
166 "alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
167 _ => None,
168 }
169}
170
171pub fn render_modifiers(
172 modifiers: &Modifiers,
173 platform_style: PlatformStyle,
174 color: Option<Color>,
175 size: Option<AbsoluteLength>,
176 trailing_separator: bool,
177) -> impl Iterator<Item = AnyElement> {
178 #[derive(Clone)]
179 enum KeyOrIcon {
180 Key(&'static str),
181 Plus,
182 Icon(IconName),
183 }
184
185 struct Modifier {
186 enabled: bool,
187 mac: KeyOrIcon,
188 linux: KeyOrIcon,
189 windows: KeyOrIcon,
190 }
191
192 let table = {
193 use KeyOrIcon::*;
194
195 [
196 Modifier {
197 enabled: modifiers.function,
198 mac: Icon(IconName::Control),
199 linux: Key("Fn"),
200 windows: Key("Fn"),
201 },
202 Modifier {
203 enabled: modifiers.control,
204 mac: Icon(IconName::Control),
205 linux: Key("Ctrl"),
206 windows: Key("Ctrl"),
207 },
208 Modifier {
209 enabled: modifiers.alt,
210 mac: Icon(IconName::Option),
211 linux: Key("Alt"),
212 windows: Key("Alt"),
213 },
214 Modifier {
215 enabled: modifiers.platform,
216 mac: Icon(IconName::Command),
217 linux: Key("Super"),
218 windows: Key("Win"),
219 },
220 Modifier {
221 enabled: modifiers.shift,
222 mac: Icon(IconName::Shift),
223 linux: Key("Shift"),
224 windows: Key("Shift"),
225 },
226 ]
227 };
228
229 let filtered = table
230 .into_iter()
231 .filter(|modifier| modifier.enabled)
232 .collect::<Vec<_>>();
233
234 let platform_keys = filtered
235 .into_iter()
236 .map(move |modifier| match platform_style {
237 PlatformStyle::Mac => Some(modifier.mac),
238 PlatformStyle::Linux => Some(modifier.linux),
239 PlatformStyle::Windows => Some(modifier.windows),
240 });
241
242 let separator = match platform_style {
243 PlatformStyle::Mac => None,
244 PlatformStyle::Linux => Some(KeyOrIcon::Plus),
245 PlatformStyle::Windows => Some(KeyOrIcon::Plus),
246 };
247
248 let platform_keys = itertools::intersperse(platform_keys, separator.clone());
249
250 platform_keys
251 .chain(if modifiers.modified() && trailing_separator {
252 Some(separator)
253 } else {
254 None
255 })
256 .flatten()
257 .map(move |key_or_icon| match key_or_icon {
258 KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(),
259 KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
260 KeyOrIcon::Plus => "+".into_any_element(),
261 })
262}
263
264#[derive(IntoElement)]
265pub struct Key {
266 key: SharedString,
267 color: Option<Color>,
268 size: Option<AbsoluteLength>,
269}
270
271impl RenderOnce for Key {
272 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
273 let single_char = self.key.len() == 1;
274 let size = self
275 .size
276 .unwrap_or_else(|| TextSize::default().rems(cx).into());
277
278 div()
279 .py_0()
280 .map(|this| {
281 if single_char {
282 this.w(size).flex().flex_none().justify_center()
283 } else {
284 this.px_0p5()
285 }
286 })
287 .h(size)
288 .text_size(size)
289 .line_height(relative(1.))
290 .text_color(self.color.unwrap_or(Color::Muted).color(cx))
291 .child(self.key.clone())
292 }
293}
294
295impl Key {
296 pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
297 Self {
298 key: key.into(),
299 color,
300 size: None,
301 }
302 }
303
304 pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
305 self.size = size.into();
306 self
307 }
308}
309
310#[derive(IntoElement)]
311pub struct KeyIcon {
312 icon: IconName,
313 color: Option<Color>,
314 size: Option<AbsoluteLength>,
315}
316
317impl RenderOnce for KeyIcon {
318 fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
319 let size = self.size.unwrap_or(IconSize::Small.rems().into());
320
321 Icon::new(self.icon)
322 .size(IconSize::Custom(size.to_rems(window.rem_size())))
323 .color(self.color.unwrap_or(Color::Muted))
324 }
325}
326
327impl KeyIcon {
328 pub fn new(icon: IconName, color: Option<Color>) -> Self {
329 Self {
330 icon,
331 color,
332 size: None,
333 }
334 }
335
336 pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
337 self.size = size.into();
338 self
339 }
340}
341
342/// Returns a textual representation of the key binding for the given [`Action`].
343pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
344 let bindings = window.bindings_for_action(action);
345 let key_binding = bindings.last()?;
346 Some(text_for_keystrokes(key_binding.keystrokes(), cx))
347}
348
349pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
350 let platform_style = PlatformStyle::platform();
351 let vim_enabled = cx.try_global::<VimStyle>().is_some();
352 keystrokes
353 .iter()
354 .map(|keystroke| keystroke_text(keystroke, platform_style, vim_enabled))
355 .join(" ")
356}
357
358pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String {
359 let platform_style = PlatformStyle::platform();
360 let vim_enabled = cx.try_global::<VimStyle>().is_some();
361 keystroke_text(keystroke, platform_style, vim_enabled)
362}
363
364/// Returns a textual representation of the given [`Keystroke`].
365fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode: bool) -> String {
366 let mut text = String::new();
367
368 let delimiter = match (platform_style, vim_mode) {
369 (PlatformStyle::Mac, false) => '-',
370 (PlatformStyle::Linux | PlatformStyle::Windows, false) => '+',
371 (_, true) => '-',
372 };
373
374 if keystroke.modifiers.function {
375 match vim_mode {
376 false => text.push_str("Fn"),
377 true => text.push_str("fn"),
378 }
379
380 text.push(delimiter);
381 }
382
383 if keystroke.modifiers.control {
384 match (platform_style, vim_mode) {
385 (PlatformStyle::Mac, false) => text.push_str("Control"),
386 (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
387 (_, true) => text.push_str("ctrl"),
388 }
389
390 text.push(delimiter);
391 }
392
393 if keystroke.modifiers.platform {
394 match (platform_style, vim_mode) {
395 (PlatformStyle::Mac, false) => text.push_str("Command"),
396 (PlatformStyle::Mac, true) => text.push_str("cmd"),
397 (PlatformStyle::Linux, false) => text.push_str("Super"),
398 (PlatformStyle::Linux, true) => text.push_str("super"),
399 (PlatformStyle::Windows, false) => text.push_str("Win"),
400 (PlatformStyle::Windows, true) => text.push_str("win"),
401 }
402
403 text.push(delimiter);
404 }
405
406 if keystroke.modifiers.alt {
407 match (platform_style, vim_mode) {
408 (PlatformStyle::Mac, false) => text.push_str("Option"),
409 (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
410 (_, true) => text.push_str("alt"),
411 }
412
413 text.push(delimiter);
414 }
415
416 if keystroke.modifiers.shift {
417 match (platform_style, vim_mode) {
418 (_, false) => text.push_str("Shift"),
419 (_, true) => text.push_str("shift"),
420 }
421 text.push(delimiter);
422 }
423
424 if vim_mode {
425 text.push_str(&keystroke.key)
426 } else {
427 let key = match keystroke.key.as_str() {
428 "pageup" => "PageUp",
429 "pagedown" => "PageDown",
430 key => &util::capitalize(key),
431 };
432 text.push_str(key);
433 }
434
435 text
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn test_text_for_keystroke() {
444 assert_eq!(
445 keystroke_text(
446 &Keystroke::parse("cmd-c").unwrap(),
447 PlatformStyle::Mac,
448 false
449 ),
450 "Command-C".to_string()
451 );
452 assert_eq!(
453 keystroke_text(
454 &Keystroke::parse("cmd-c").unwrap(),
455 PlatformStyle::Linux,
456 false
457 ),
458 "Super+C".to_string()
459 );
460 assert_eq!(
461 keystroke_text(
462 &Keystroke::parse("cmd-c").unwrap(),
463 PlatformStyle::Windows,
464 false
465 ),
466 "Win+C".to_string()
467 );
468
469 assert_eq!(
470 keystroke_text(
471 &Keystroke::parse("ctrl-alt-delete").unwrap(),
472 PlatformStyle::Mac,
473 false
474 ),
475 "Control-Option-Delete".to_string()
476 );
477 assert_eq!(
478 keystroke_text(
479 &Keystroke::parse("ctrl-alt-delete").unwrap(),
480 PlatformStyle::Linux,
481 false
482 ),
483 "Ctrl+Alt+Delete".to_string()
484 );
485 assert_eq!(
486 keystroke_text(
487 &Keystroke::parse("ctrl-alt-delete").unwrap(),
488 PlatformStyle::Windows,
489 false
490 ),
491 "Ctrl+Alt+Delete".to_string()
492 );
493
494 assert_eq!(
495 keystroke_text(
496 &Keystroke::parse("shift-pageup").unwrap(),
497 PlatformStyle::Mac,
498 false
499 ),
500 "Shift-PageUp".to_string()
501 );
502 assert_eq!(
503 keystroke_text(
504 &Keystroke::parse("shift-pageup").unwrap(),
505 PlatformStyle::Linux,
506 false,
507 ),
508 "Shift+PageUp".to_string()
509 );
510 assert_eq!(
511 keystroke_text(
512 &Keystroke::parse("shift-pageup").unwrap(),
513 PlatformStyle::Windows,
514 false
515 ),
516 "Shift+PageUp".to_string()
517 );
518 }
519}