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