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