1use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px};
4use gpui_kit_semantics::{NodeSpec, Role, Semantic};
5
6use crate::foundation::{ActiveTheme, Ident, StyledExt};
7use crate::strings::{ActiveStrings, StringKey, Strings};
8
9#[derive(Debug, Clone, IntoElement)]
15pub struct Kbd {
16 keystroke: SharedString,
17 ident: Option<Ident>,
18}
19
20impl Kbd {
21 pub fn new(keystroke: impl Into<SharedString>) -> Self {
23 Self {
24 keystroke: keystroke.into(),
25 ident: None,
26 }
27 }
28
29 pub fn id(mut self, ident: impl Into<Ident>) -> Self {
32 self.ident = Some(ident.into());
33 self
34 }
35
36 pub fn caps(&self, cx: &App) -> Vec<SharedString> {
37 caps(
38 self.keystroke.as_ref(),
39 cfg!(target_os = "macos"),
40 cx.strings(),
41 )
42 }
43}
44
45impl RenderOnce for Kbd {
46 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
47 let theme = cx.theme().clone();
48 let published = self.ident.as_ref().map(|ident| {
49 NodeSpec::new(ident.semantic_id(), Role::Text).text(self.keystroke.clone())
50 });
51 let element =
52 div()
53 .row()
54 .gap(px(theme.spacing.xs / 2.0))
55 .children(self.caps(cx).into_iter().map(|cap| {
56 div()
57 .h(px(theme
58 .control
59 .get(gpui_kit_theme::ControlSize::Sm)
60 .height))
61 .min_w(px(theme
62 .control
63 .get(gpui_kit_theme::ControlSize::Sm)
64 .height))
65 .px(px(theme.spacing.xs))
66 .flex()
67 .items_center()
68 .justify_center()
69 .radius(&theme, gpui_kit_theme::Radius::Small)
70 .bg(theme.colors.hover)
71 .font_family(theme.typography.mono.clone())
72 .text_size(px(theme.typography.caption.size))
73 .text_color(theme.colors.text_muted)
74 .child(cap)
75 }));
76 match published {
77 Some(spec) => element.semantic_in(cx, spec).into_any_element(),
78 None => element.into_any_element(),
79 }
80 }
81}
82
83pub fn caps(keystroke: &str, macos: bool, strings: &Strings) -> Vec<SharedString> {
87 let mut modifiers = String::new();
88 let mut caps: Vec<SharedString> = Vec::new();
89 let parts: Vec<&str> = keystroke
90 .split('-')
91 .filter(|part| !part.is_empty())
92 .collect();
93 let Some((key, modifier_parts)) = parts.split_last() else {
94 return Vec::new();
95 };
96
97 for modifier in modifier_parts {
98 let label = modifier_label(modifier, macos, strings);
99 if macos {
100 modifiers.push_str(&label);
101 } else {
102 caps.push(label.into());
103 }
104 }
105 let key = key_label(key, macos);
106 if macos {
107 modifiers.push_str(&key);
108 vec![modifiers.into()]
109 } else {
110 caps.push(key.into());
111 caps
112 }
113}
114
115fn modifier_label(modifier: &str, macos: bool, strings: &Strings) -> String {
123 match (modifier, macos) {
124 ("cmd" | "super" | "win", true) => "⌘".into(),
125 ("cmd" | "super" | "win", false) => strings.text(StringKey::KbdSuper).to_string(),
126 ("ctrl" | "control", true) => "⌃".into(),
127 ("ctrl" | "control", false) => strings.text(StringKey::KbdControl).to_string(),
128 ("alt" | "option", true) => "⌥".into(),
129 ("alt" | "option", false) => strings.text(StringKey::KbdAlt).to_string(),
130 ("shift", true) => "⇧".into(),
131 ("shift", false) => strings.text(StringKey::KbdShift).to_string(),
132 (other, _) => capitalize(other),
133 }
134}
135
136fn key_label(key: &str, macos: bool) -> String {
137 match (key, macos) {
138 ("enter", true) => "⏎".into(),
141 ("escape", true) => "esc".into(),
142 ("backspace", true) => "⌫".into(),
143 ("delete", true) => "⌦".into(),
144 ("tab", true) => "⇥".into(),
145 ("up", _) => "↑".into(),
146 ("down", _) => "↓".into(),
147 ("left", _) => "←".into(),
148 ("right", _) => "→".into(),
149 ("space", _) => "␣".into(),
150 (other, _) if other.chars().count() == 1 => other.to_uppercase(),
151 (other, _) => capitalize(other),
152 }
153}
154
155fn capitalize(value: &str) -> String {
156 let mut characters = value.chars();
157 match characters.next() {
158 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
159 None => String::new(),
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn macos_composes_modifiers_into_one_cap() {
169 assert_eq!(
170 caps("cmd-shift-p", true, &Strings::new()),
171 vec![SharedString::from("⌘⇧P")]
172 );
173 }
174
175 #[test]
176 fn other_platforms_spell_each_modifier_out() {
177 assert_eq!(
178 caps("ctrl-shift-p", false, &Strings::new()),
179 vec![
180 SharedString::from("Ctrl"),
181 SharedString::from("Shift"),
182 SharedString::from("P")
183 ]
184 );
185 }
186
187 #[test]
188 fn named_keys_use_their_symbols_where_the_platform_expects_them() {
189 assert_eq!(
190 caps("enter", true, &Strings::new()),
191 vec![SharedString::from("⏎")]
192 );
193 assert_eq!(
194 caps("enter", false, &Strings::new()),
195 vec![SharedString::from("Enter")]
196 );
197 assert_eq!(
198 caps("up", false, &Strings::new()),
199 vec![SharedString::from("↑")]
200 );
201 }
202
203 #[test]
204 fn an_empty_keystroke_draws_nothing() {
205 assert!(caps("", true, &Strings::new()).is_empty());
206 }
207}