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