1use gpui::{
2 div, relative, Action, AsKeystroke, IntoElement, KeyContext, Keystroke, ParentElement as _,
3 RenderOnce, StyleRefinement, Styled, Window,
4};
5
6use crate::{ActiveTheme, StyledExt};
7
8#[derive(IntoElement, Clone, Debug)]
10pub struct Kbd {
11 style: StyleRefinement,
12 stroke: Keystroke,
13 appearance: bool,
14}
15
16impl From<Keystroke> for Kbd {
17 fn from(stroke: Keystroke) -> Self {
18 Self {
19 style: StyleRefinement::default(),
20 stroke,
21 appearance: true,
22 }
23 }
24}
25
26impl Kbd {
27 pub fn new(stroke: Keystroke) -> Self {
28 Self {
29 style: StyleRefinement::default(),
30 stroke,
31 appearance: true,
32 }
33 }
34
35 pub fn appearance(mut self, appearance: bool) -> Self {
37 self.appearance = appearance;
38 self
39 }
40
41 pub fn binding_for_action(
43 action: &dyn Action,
44 context: Option<&str>,
45 window: &Window,
46 ) -> Option<Self> {
47 let key_context = context.and_then(|context| KeyContext::parse(context).ok());
48 let binding = match key_context {
49 Some(context) => {
50 window.highest_precedence_binding_for_action_in_context(action, context)
51 }
52 None => window.highest_precedence_binding_for_action(action),
53 }?;
54
55 if let Some(key) = binding.keystrokes().first() {
56 Some(Self::new(key.as_keystroke().clone()))
57 } else {
58 None
59 }
60 }
61
62 pub fn format(key: &Keystroke) -> String {
67 #[cfg(target_os = "macos")]
68 const DIVIDER: &str = "";
69 #[cfg(not(target_os = "macos"))]
70 const DIVIDER: &str = "+";
71
72 let mut parts = vec![];
73
74 if key.modifiers.control {
78 #[cfg(target_os = "macos")]
79 parts.push("⌃");
80
81 #[cfg(not(target_os = "macos"))]
82 parts.push("Ctrl");
83 }
84
85 if key.modifiers.alt {
86 #[cfg(target_os = "macos")]
87 parts.push("⌥");
88
89 #[cfg(not(target_os = "macos"))]
90 parts.push("Alt");
91 }
92
93 if key.modifiers.shift {
94 #[cfg(target_os = "macos")]
95 parts.push("⇧");
96
97 #[cfg(not(target_os = "macos"))]
98 parts.push("Shift");
99 }
100
101 if key.modifiers.platform {
102 #[cfg(target_os = "macos")]
103 parts.push("⌘");
104
105 #[cfg(not(target_os = "macos"))]
106 parts.push("Win");
107 }
108
109 let mut keys = String::new();
110 let key_str = key.key.as_str();
111 match key_str {
112 #[cfg(target_os = "macos")]
113 "ctrl" => keys.push('⌃'),
114 #[cfg(not(target_os = "macos"))]
115 "ctrl" => keys.push_str("Ctrl"),
116 #[cfg(target_os = "macos")]
117 "alt" => keys.push('⌥'),
118 #[cfg(not(target_os = "macos"))]
119 "alt" => keys.push_str("Alt"),
120 #[cfg(target_os = "macos")]
121 "shift" => keys.push('⇧'),
122 #[cfg(not(target_os = "macos"))]
123 "shift" => keys.push_str("Shift"),
124 #[cfg(target_os = "macos")]
125 "cmd" => keys.push('⌘'),
126 #[cfg(not(target_os = "macos"))]
127 "cmd" => keys.push_str("Win"),
128 #[cfg(target_os = "macos")]
129 "space" => keys.push_str("Space"),
130 #[cfg(target_os = "macos")]
131 "backspace" => keys.push('⌫'),
132 #[cfg(not(target_os = "macos"))]
133 "backspace" => keys.push_str("Backspace"),
134 #[cfg(target_os = "macos")]
135 "delete" => keys.push('⌫'),
136 #[cfg(not(target_os = "macos"))]
137 "delete" => keys.push_str("Delete"),
138 #[cfg(target_os = "macos")]
139 "escape" => keys.push('⎋'),
140 #[cfg(not(target_os = "macos"))]
141 "escape" => keys.push_str("Esc"),
142 #[cfg(target_os = "macos")]
143 "enter" => keys.push('⏎'),
144 #[cfg(not(target_os = "macos"))]
145 "enter" => keys.push_str("Enter"),
146 "pagedown" => keys.push_str("Page Down"),
147 "pageup" => keys.push_str("Page Up"),
148 #[cfg(target_os = "macos")]
149 "left" => keys.push('←'),
150 #[cfg(not(target_os = "macos"))]
151 "left" => keys.push_str("Left"),
152 #[cfg(target_os = "macos")]
153 "right" => keys.push('→'),
154 #[cfg(not(target_os = "macos"))]
155 "right" => keys.push_str("Right"),
156 #[cfg(target_os = "macos")]
157 "up" => keys.push('↑'),
158 #[cfg(not(target_os = "macos"))]
159 "up" => keys.push_str("Up"),
160 #[cfg(target_os = "macos")]
161 "down" => keys.push('↓'),
162 #[cfg(not(target_os = "macos"))]
163 "down" => keys.push_str("Down"),
164 _ => {
165 if key_str.len() == 1 {
166 keys.push_str(&key_str.to_uppercase());
167 } else {
168 let mut chars = key_str.chars();
169 if let Some(first_char) = chars.next() {
170 keys.push_str(&format!(
171 "{}{}",
172 first_char.to_uppercase(),
173 chars.collect::<String>()
174 ));
175 } else {
176 keys.push_str(&key_str);
177 }
178 }
179 }
180 }
181
182 parts.push(&keys);
183 parts.join(DIVIDER)
184 }
185}
186
187impl Styled for Kbd {
188 fn style(&mut self) -> &mut StyleRefinement {
189 &mut self.style
190 }
191}
192
193impl RenderOnce for Kbd {
194 fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
195 if !self.appearance {
196 return Self::format(&self.stroke).into_any_element();
197 }
198
199 div()
200 .border_1()
201 .border_color(cx.theme().border)
202 .text_color(cx.theme().muted_foreground)
203 .bg(cx.theme().background)
204 .py_0p5()
205 .px_1()
206 .min_w_5()
207 .text_center()
208 .rounded_sm()
209 .line_height(relative(1.))
210 .text_xs()
211 .whitespace_normal()
212 .flex_shrink_0()
213 .refine_style(&self.style)
214 .child(Self::format(&self.stroke))
215 .into_any_element()
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 #[test]
222 fn test_format() {
223 use super::Kbd;
224 use gpui::Keystroke;
225
226 if cfg!(target_os = "macos") {
227 assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
228 assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
229 assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
230 assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
231 assert_eq!(
232 Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
233 "⌘F12"
234 );
235 assert_eq!(
236 Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
237 "⇧Page Down"
238 );
239 assert_eq!(
240 Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
241 "⇧Page Up"
242 );
243 assert_eq!(
244 Kbd::format(&Keystroke::parse("shift-space").unwrap()),
245 "⇧Space"
246 );
247 assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
248 assert_eq!(
249 Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
250 "⌥⌘⌫"
251 );
252 assert_eq!(
253 Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
254 "⇧⌫"
255 );
256 assert_eq!(
257 Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
258 "⌃⇧⌘A"
259 );
260 assert_eq!(
261 Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
262 "⌃⌥⇧⌘A"
263 );
264 } else {
265 assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
266 assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
267 assert_eq!(
268 Kbd::format(&Keystroke::parse("shift-space").unwrap()),
269 "Shift+Space"
270 );
271 assert_eq!(
272 Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
273 "Ctrl+Alt+A"
274 );
275 assert_eq!(
276 Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
277 "Ctrl+Alt+Shift+A"
278 );
279 assert_eq!(
280 Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
281 "Ctrl+Alt+Shift+Win+A"
282 );
283 assert_eq!(
284 Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
285 "Ctrl+Shift+Backspace"
286 );
287 assert_eq!(
288 Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
289 "Alt+Delete"
290 );
291 assert_eq!(
292 Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
293 "Alt+Tab"
294 );
295 }
296 }
297}