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