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