1use gpui::{
2 div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
3 ParentElement as _, 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 binding_for_action_in(
64 action: &dyn Action,
65 focus_handle: &FocusHandle,
66 window: &Window,
67 ) -> Option<Self> {
68 let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
69 if let Some(key) = binding.keystrokes().first() {
70 Some(Self::new(key.as_keystroke().clone()))
71 } else {
72 None
73 }
74 }
75
76 pub fn format(key: &Keystroke) -> String {
81 #[cfg(target_os = "macos")]
82 const DIVIDER: &str = "";
83 #[cfg(not(target_os = "macos"))]
84 const DIVIDER: &str = "+";
85
86 let mut parts = vec![];
87
88 if key.modifiers.control {
92 #[cfg(target_os = "macos")]
93 parts.push("⌃");
94
95 #[cfg(not(target_os = "macos"))]
96 parts.push("Ctrl");
97 }
98
99 if key.modifiers.alt {
100 #[cfg(target_os = "macos")]
101 parts.push("⌥");
102
103 #[cfg(not(target_os = "macos"))]
104 parts.push("Alt");
105 }
106
107 if key.modifiers.shift {
108 #[cfg(target_os = "macos")]
109 parts.push("⇧");
110
111 #[cfg(not(target_os = "macos"))]
112 parts.push("Shift");
113 }
114
115 if key.modifiers.platform {
116 #[cfg(target_os = "macos")]
117 parts.push("⌘");
118
119 #[cfg(not(target_os = "macos"))]
120 parts.push("Win");
121 }
122
123 let mut keys = String::new();
124 let key_str = key.key.as_str();
125 match key_str {
126 #[cfg(target_os = "macos")]
127 "ctrl" => keys.push('⌃'),
128 #[cfg(not(target_os = "macos"))]
129 "ctrl" => keys.push_str("Ctrl"),
130 #[cfg(target_os = "macos")]
131 "alt" => keys.push('⌥'),
132 #[cfg(not(target_os = "macos"))]
133 "alt" => keys.push_str("Alt"),
134 #[cfg(target_os = "macos")]
135 "shift" => keys.push('⇧'),
136 #[cfg(not(target_os = "macos"))]
137 "shift" => keys.push_str("Shift"),
138 #[cfg(target_os = "macos")]
139 "cmd" => keys.push('⌘'),
140 #[cfg(not(target_os = "macos"))]
141 "cmd" => keys.push_str("Win"),
142 #[cfg(target_os = "macos")]
143 "space" => keys.push_str("Space"),
144 #[cfg(target_os = "macos")]
145 "backspace" => keys.push('⌫'),
146 #[cfg(not(target_os = "macos"))]
147 "backspace" => keys.push_str("Backspace"),
148 #[cfg(target_os = "macos")]
149 "delete" => keys.push('⌫'),
150 #[cfg(not(target_os = "macos"))]
151 "delete" => keys.push_str("Delete"),
152 #[cfg(target_os = "macos")]
153 "escape" => keys.push('⎋'),
154 #[cfg(not(target_os = "macos"))]
155 "escape" => keys.push_str("Esc"),
156 #[cfg(target_os = "macos")]
157 "enter" => keys.push('⏎'),
158 #[cfg(not(target_os = "macos"))]
159 "enter" => keys.push_str("Enter"),
160 "pagedown" => keys.push_str("Page Down"),
161 "pageup" => keys.push_str("Page Up"),
162 #[cfg(target_os = "macos")]
163 "left" => keys.push('←'),
164 #[cfg(not(target_os = "macos"))]
165 "left" => keys.push_str("Left"),
166 #[cfg(target_os = "macos")]
167 "right" => keys.push('→'),
168 #[cfg(not(target_os = "macos"))]
169 "right" => keys.push_str("Right"),
170 #[cfg(target_os = "macos")]
171 "up" => keys.push('↑'),
172 #[cfg(not(target_os = "macos"))]
173 "up" => keys.push_str("Up"),
174 #[cfg(target_os = "macos")]
175 "down" => keys.push('↓'),
176 #[cfg(not(target_os = "macos"))]
177 "down" => keys.push_str("Down"),
178 _ => {
179 if key_str.len() == 1 {
180 keys.push_str(&key_str.to_uppercase());
181 } else {
182 let mut chars = key_str.chars();
183 if let Some(first_char) = chars.next() {
184 keys.push_str(&format!(
185 "{}{}",
186 first_char.to_uppercase(),
187 chars.collect::<String>()
188 ));
189 } else {
190 keys.push_str(&key_str);
191 }
192 }
193 }
194 }
195
196 parts.push(&keys);
197 parts.join(DIVIDER)
198 }
199}
200
201impl Styled for Kbd {
202 fn style(&mut self) -> &mut StyleRefinement {
203 &mut self.style
204 }
205}
206
207impl RenderOnce for Kbd {
208 fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
209 if !self.appearance {
210 return Self::format(&self.stroke).into_any_element();
211 }
212
213 div()
214 .border_1()
215 .border_color(cx.theme().border)
216 .text_color(cx.theme().muted_foreground)
217 .bg(cx.theme().background)
218 .py_0p5()
219 .px_1()
220 .min_w_5()
221 .text_center()
222 .rounded_sm()
223 .line_height(relative(1.))
224 .text_xs()
225 .whitespace_normal()
226 .flex_shrink_0()
227 .refine_style(&self.style)
228 .child(Self::format(&self.stroke))
229 .into_any_element()
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 #[test]
236 fn test_format() {
237 use super::Kbd;
238 use gpui::Keystroke;
239
240 if cfg!(target_os = "macos") {
241 assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
242 assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
243 assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
244 assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
245 assert_eq!(
246 Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
247 "⌘F12"
248 );
249 assert_eq!(
250 Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
251 "⇧Page Down"
252 );
253 assert_eq!(
254 Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
255 "⇧Page Up"
256 );
257 assert_eq!(
258 Kbd::format(&Keystroke::parse("shift-space").unwrap()),
259 "⇧Space"
260 );
261 assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
262 assert_eq!(
263 Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
264 "⌥⌘⌫"
265 );
266 assert_eq!(
267 Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
268 "⇧⌫"
269 );
270 assert_eq!(
271 Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
272 "⌃⇧⌘A"
273 );
274 assert_eq!(
275 Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
276 "⌃⌥⇧⌘A"
277 );
278 } else {
279 assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
280 assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
281 assert_eq!(
282 Kbd::format(&Keystroke::parse("shift-space").unwrap()),
283 "Shift+Space"
284 );
285 assert_eq!(
286 Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
287 "Ctrl+Alt+A"
288 );
289 assert_eq!(
290 Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
291 "Ctrl+Alt+Shift+A"
292 );
293 assert_eq!(
294 Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
295 "Ctrl+Alt+Shift+Win+A"
296 );
297 assert_eq!(
298 Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
299 "Ctrl+Shift+Backspace"
300 );
301 assert_eq!(
302 Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
303 "Alt+Delete"
304 );
305 assert_eq!(
306 Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
307 "Alt+Tab"
308 );
309 }
310 }
311}