Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/ui_components/keyboard_shortcut.rs
StratoSDK / crates / strato-ui-core / src / ui_components / keyboard_shortcut.rs
1use std::borrow::Cow;
2use std::sync::Arc;
3 
4use itertools::Itertools;
5 
6use crate::elements::{Icon, DEFAULT_UI_LINE_HEIGHT_RATIO};
7use crate::{
8 elements::{
9 Align, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, MinSize, ParentElement,
10 },
11 keymap::Keystroke,
12 platform::OperatingSystem,
13 scene::Border,
14};
15 
16use super::{
17 components::{UiComponent, UiComponentStyles},
18 text::Span,
19};
20 
21type IconForKeystrokeFn = Arc<dyn Fn(&str) -> Option<Icon>>;
22 
23/// UI Component representing a keyboard shortcut, can be styled using `UiComponent::with_style`
24#[derive(Clone)]
25pub struct KeyboardShortcut {
26 keys: Vec<Key>,
27 style: UiComponentStyles,
28 is_lowercase_modifier: bool,
29 is_text_only: bool,
30 space_between_keys: f32,
31 line_height_ratio: f32,
32 icon_for_keystroke: IconForKeystrokeFn,
33}
34 
35impl KeyboardShortcut {
36 pub fn new(keystroke: &Keystroke, style: UiComponentStyles) -> Self {
37 Self {
38 keys: keystroke_to_keys(keystroke),
39 style,
40 is_lowercase_modifier: false,
41 is_text_only: false,
42 space_between_keys: 3.,
43 line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
44 icon_for_keystroke: Arc::new(|_| None),
45 }
46 }
47 
48 pub fn lowercase_modifier(mut self) -> Self {
49 self.is_lowercase_modifier = true;
50 self
51 }
52 
53 pub fn text_only(mut self) -> Self {
54 self.is_text_only = true;
55 self
56 }
57 
58 pub fn with_space_between_keys(mut self, spacing: f32) -> Self {
59 self.space_between_keys = spacing;
60 self
61 }
62 
63 pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self {
64 self.line_height_ratio = line_height_ratio;
65 self
66 }
67 
68 pub fn with_icon_for_keystroke(
69 mut self,
70 icon_for_keystroke: impl Fn(&str) -> Option<Icon> + 'static,
71 ) -> Self {
72 self.icon_for_keystroke = Arc::new(icon_for_keystroke);
73 self
74 }
75}
76 
77impl UiComponent for KeyboardShortcut {
78 type ElementType = Container;
79 
80 fn build(self) -> Container {
81 let keys = if self.is_text_only {
82 // On Mac, we use symbols for modifiers so we don't need a separator.
83 // On other OS, we spell out modifiers so they need to be separated by space
84 let sep = if OperatingSystem::get().is_mac() {
85 ""
86 } else {
87 " "
88 };
89 let combined_text = self
90 .keys
91 .iter()
92 .map(|key| key.text(self.is_lowercase_modifier))
93 .join(sep);
94 
95 let text_element = Align::new(
96 Span::new(
97 combined_text,
98 // Removing any margin from the style passed to Span, since we process it below
99 self.style,
100 )
101 .with_line_height_ratio(self.line_height_ratio)
102 .with_selectable(false)
103 .build()
104 .finish(),
105 )
106 .finish();
107 Flex::row()
108 .with_cross_axis_alignment(CrossAxisAlignment::Center)
109 .with_child(text_element)
110 } else {
111 Flex::row()
112 .with_cross_axis_alignment(CrossAxisAlignment::Center)
113 .with_children(self.keys.iter().enumerate().map(|(i, key)| {
114 if i == 0 {
115 key.render(
116 self.style,
117 self.is_lowercase_modifier,
118 self.line_height_ratio,
119 self.icon_for_keystroke.as_ref(),
120 )
121 } else {
122 Container::new(key.render(
123 self.style,
124 self.is_lowercase_modifier,
125 self.line_height_ratio,
126 self.icon_for_keystroke.as_ref(),
127 ))
128 .with_margin_left(self.space_between_keys)
129 .finish()
130 }
131 }))
132 };
133 
134 let mut keys = Container::new(keys.finish());
135 
136 if let Some(margin) = self.style.margin {
137 keys = keys
138 .with_margin_top(margin.top)
139 .with_margin_right(margin.right)
140 .with_margin_bottom(margin.bottom)
141 .with_margin_left(margin.left);
142 }
143 
144 keys
145 }
146 
147 fn with_style(mut self, style: UiComponentStyles) -> Self {
148 self.style = self.style.merge(style);
149 self
150 }
151}
152 
153pub fn keystroke_to_keys(keystroke: &Keystroke) -> Vec<Key> {
154 let mut keys = Vec::new();
155 // Note: The order of the modifiers is intentional, to match the VS Code command palette
156 if keystroke.ctrl {
157 keys.push(Key::Control);
158 }
159 
160 if keystroke.shift {
161 keys.push(Key::Shift);
162 }
163 
164 if keystroke.meta {
165 keys.push(Key::Meta);
166 }
167 
168 if keystroke.alt {
169 keys.push(Key::Option);
170 }
171 
172 if keystroke.cmd {
173 keys.push(Key::Command);
174 }
175 
176 keys.push(Key::Other(keystroke.key.clone()));
177 keys
178}
179 
180#[derive(Clone)]
181pub enum Key {
182 Command,
183 Option,
184 Control,
185 Shift,
186 Meta,
187 Other(String),
188}
189 
190impl Key {
191 pub fn text(&self, is_lowercase_modifier: bool) -> Cow<'static, str> {
192 let mut text: Cow<'static, str> = match self {
193 Key::Command => {
194 if OperatingSystem::get().is_mac() {
195 "⌘".into()
196 } else {
197 "Logo".into()
198 }
199 }
200 Key::Option => {
201 if OperatingSystem::get().is_mac() {
202 "⌥".into()
203 } else {
204 "Alt".into()
205 }
206 }
207 Key::Control => {
208 if OperatingSystem::get().is_mac() {
209 "⌃".into()
210 } else {
211 "Ctrl".into()
212 }
213 }
214 Key::Shift => {
215 if OperatingSystem::get().is_mac() {
216 "⇧".into()
217 } else {
218 "Shift".into()
219 }
220 }
221 Key::Meta => "Meta".into(),
222 Key::Other(key) => match key.as_str() {
223 "up" => "↑".into(),
224 "down" => "↓".into(),
225 "left" => "←".into(),
226 "right" => "→".into(),
227 "\t" => "Tab".into(),
228 " " => "Space".into(),
229 "escape" => "ESC".into(),
230 "enter" => "⏎".into(),
231 "backspace" => "⌫".into(),
232 _ => {
233 // Capitalize the first letter of the key name
234 key.chars()
235 .next()
236 .map(|c| c.to_ascii_uppercase())
237 .into_iter()
238 .chain(key.chars().skip(1))
239 .collect()
240 }
241 },
242 };
243 // Single character keys should still be uppercase.
244 if text.len() > 1 && is_lowercase_modifier {
245 text = text.to_lowercase().into();
246 }
247 text
248 }
249 
250 fn render(
251 &self,
252 style: UiComponentStyles,
253 is_lowercase_modifier: bool,
254 line_height_ratio: f32,
255 icon_for_keystroke: &dyn Fn(&str) -> Option<Icon>,
256 ) -> Box<dyn Element> {
257 let text = self.text(is_lowercase_modifier);
258 
259 let (content, is_multi_char_key) = if let Some(mut icon) = icon_for_keystroke(text.as_ref())
260 {
261 if let Some(font_color) = style.font_color {
262 icon = icon.with_color(font_color);
263 }
264 let size = style.font_size.unwrap_or_default();
265 let icon = ConstrainedBox::new(icon.finish())
266 .with_height(size)
267 .with_width(size)
268 .finish();
269 (icon, false)
270 } else {
271 let is_multi_char_key = text.chars().count() > 1;
272 let content = Span::new(
273 text,
274 // Removing any margin from the style passed to Span, since we process it below
275 UiComponentStyles {
276 margin: None,
277 ..style
278 },
279 )
280 .with_line_height_ratio(line_height_ratio)
281 .with_selectable(false)
282 .build()
283 .finish();
284 
285 (content, is_multi_char_key)
286 };
287 
288 let mut background = Container::new(MinSize::new(content).finish());
289 
290 let mut border = Border::all(style.border_width.unwrap_or_default());
291 if let Some(border_color) = style.border_color {
292 border = border.with_border_fill(border_color);
293 }
294 background = background.with_border(border);
295 
296 if let Some(padding) = style.padding {
297 background = background
298 .with_padding_top(padding.top)
299 .with_padding_right(padding.right)
300 .with_padding_bottom(padding.bottom)
301 .with_padding_left(padding.left);
302 }
303 
304 if is_multi_char_key
305 && (style
306 .padding
307 .is_some_and(|padding| padding.left == 0. && padding.right == 0.)
308 || style.padding.is_none())
309 {
310 // If this shortcut is for a keystroke represented with multiple chars and there is
311 // no specified horizontal padding, add a default 4px horizontal padding. Because
312 // it's multiple chars, itll exceed the given width constraint and leave you with a
313 // shortcut with no padding.
314 background = background.with_horizontal_padding(4.);
315 }
316 if let Some(radius) = style.border_radius {
317 background = background.with_corner_radius(radius);
318 }
319 if let Some(background_color) = style.background {
320 background = background.with_background(background_color);
321 }
322 
323 let mut sized = ConstrainedBox::new(background.finish());
324 match (style.width, style.height) {
325 (Some(width), Some(height)) => {
326 // If the height is set, use it as a minimum. If the content doesn't fill the
327 // given height, grow each key to fit. If the content exceeds the given height,
328 // allow it to grow to fit. This should not result in inconsistent heights since
329 // each key will require the same amount of extra height (assuming all use the
330 // same font, font size, and padding).
331 // Allow the width to grow as needed to fit the content.
332 sized = sized.with_min_width(width).with_min_height(height);
333 }
334 (None, Some(height)) => {
335 // Make the minimum size a square as suggested by design if no width is given.
336 sized = sized.with_min_width(height).with_min_height(height);
337 }
338 (Some(width), None) => {
339 // Allow the width to grow as needed to fit the content.
340 sized = sized.with_min_width(width);
341 }
342 (None, None) => (),
343 }
344 
345 sized.finish()
346 }
347}
348