1use std::fmt;
5
6coded_enum! {
7 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9 pub enum Key {
10 A,
11 B,
12 C,
13 D,
14 E,
15 F,
16 G,
17 H,
18 I,
19 J,
20 K,
21 L,
22 M,
23 N,
24 O,
25 P,
26 Q,
27 R,
28 S,
29 T,
30 U,
31 V,
32 W,
33 X,
34 Y,
35 Z,
36 Digit0,
37 Digit1,
38 Digit2,
39 Digit3,
40 Digit4,
41 Digit5,
42 Digit6,
43 Digit7,
44 Digit8,
45 Digit9,
46 Comma,
47 Period,
48 Slash,
49 Semicolon,
50 Quote,
51 LeftBracket,
52 RightBracket,
53 Minus,
54 Equals,
55 Return,
56 Delete,
57 Up,
58 Down,
59 Left,
60 Right,
61 }
62}
63
64impl Key {
65 #[must_use]
67 pub fn name(self) -> &'static str {
68 match self {
69 Self::A => "a",
70 Self::B => "b",
71 Self::C => "c",
72 Self::D => "d",
73 Self::E => "e",
74 Self::F => "f",
75 Self::G => "g",
76 Self::H => "h",
77 Self::I => "i",
78 Self::J => "j",
79 Self::K => "k",
80 Self::L => "l",
81 Self::M => "m",
82 Self::N => "n",
83 Self::O => "o",
84 Self::P => "p",
85 Self::Q => "q",
86 Self::R => "r",
87 Self::S => "s",
88 Self::T => "t",
89 Self::U => "u",
90 Self::V => "v",
91 Self::W => "w",
92 Self::X => "x",
93 Self::Y => "y",
94 Self::Z => "z",
95 Self::Digit0 => "0",
96 Self::Digit1 => "1",
97 Self::Digit2 => "2",
98 Self::Digit3 => "3",
99 Self::Digit4 => "4",
100 Self::Digit5 => "5",
101 Self::Digit6 => "6",
102 Self::Digit7 => "7",
103 Self::Digit8 => "8",
104 Self::Digit9 => "9",
105 Self::Comma => ",",
106 Self::Period => ".",
107 Self::Slash => "/",
108 Self::Semicolon => ";",
109 Self::Quote => "'",
110 Self::LeftBracket => "[",
111 Self::RightBracket => "]",
112 Self::Minus => "-",
113 Self::Equals => "=",
114 Self::Return => "return",
115 Self::Delete => "delete",
116 Self::Up => "up",
117 Self::Down => "down",
118 Self::Left => "left",
119 Self::Right => "right",
120 }
121 }
122
123 #[must_use]
125 pub fn character(self) -> Option<char> {
126 let mut chars = self.name().chars();
127 match (chars.next(), chars.next()) {
128 (Some(character), None) => Some(character),
129 _ => None,
130 }
131 }
132
133 fn named(name: &str) -> Option<Self> {
134 Self::ALL.iter().copied().find(|key| key.name() == name)
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
142#[repr(C)]
143pub struct Shortcut {
144 pub key: Key,
145 pub shift: bool,
146 pub alt: bool,
147}
148
149pub const SHIFT: &str = "shift";
151pub const ALT: &str = "alt";
152
153impl Shortcut {
154 #[must_use]
155 pub const fn command(key: Key) -> Self {
156 Self {
157 key,
158 shift: false,
159 alt: false,
160 }
161 }
162
163 pub fn parse(text: &str) -> Result<Self, ShortcutError> {
169 let (modifiers, key) = text.rsplit_once('+').unwrap_or(("", text));
170 let key = Key::named(key).ok_or_else(|| ShortcutError::Key(key.to_owned()))?;
171 let mut shortcut = Self::command(key);
172 for modifier in modifiers.split('+').filter(|modifier| !modifier.is_empty()) {
173 let held = match modifier {
174 SHIFT => &mut shortcut.shift,
175 ALT => &mut shortcut.alt,
176 other => return Err(ShortcutError::Modifier(other.to_owned())),
177 };
178 if std::mem::replace(held, true) {
179 return Err(ShortcutError::Repeated(modifier.to_owned()));
180 }
181 }
182 Ok(shortcut)
183 }
184
185 #[must_use]
187 pub fn taken_by(self) -> Option<SystemCommand> {
188 SystemCommand::ALL
189 .iter()
190 .copied()
191 .find(|command| command.shortcut() == self)
192 }
193}
194
195impl fmt::Display for Shortcut {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 if self.shift {
199 write!(f, "{SHIFT}+")?;
200 }
201 if self.alt {
202 write!(f, "{ALT}+")?;
203 }
204 f.write_str(self.key.name())
205 }
206}
207
208#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum ShortcutError {
211 Key(String),
212 Modifier(String),
213 Repeated(String),
214}
215
216impl fmt::Display for ShortcutError {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 match self {
219 Self::Key(key) => write!(
220 f,
221 "`{key}` isn't a key a shortcut can use; use a letter, a digit, one of `, . / ; ' [ ] - =`, or `return`, `delete`, `up`, `down`, `left` or `right`"
222 ),
223 Self::Modifier(modifier) => write!(
224 f,
225 "`{modifier}` isn't a modifier; a shortcut always uses Cmd on macOS and Ctrl on Windows, and may add `{SHIFT}` and `{ALT}`"
226 ),
227 Self::Repeated(modifier) => write!(f, "`{modifier}` is written twice"),
228 }
229 }
230}
231
232coded_enum! {
233 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
236 pub enum SystemCommand {
237 Quit,
238 Undo,
239 Redo,
240 Cut,
241 Copy,
242 Paste,
243 SelectAll,
244 }
245}
246
247impl SystemCommand {
248 #[must_use]
249 pub fn title(self) -> &'static str {
250 match self {
251 Self::Quit => "Quit",
252 Self::Undo => "Undo",
253 Self::Redo => "Redo",
254 Self::Cut => "Cut",
255 Self::Copy => "Copy",
256 Self::Paste => "Paste",
257 Self::SelectAll => "Select All",
258 }
259 }
260
261 #[must_use]
262 pub const fn shortcut(self) -> Shortcut {
263 match self {
264 Self::Quit => Shortcut::command(Key::Q),
265 Self::Undo => Shortcut::command(Key::Z),
266 Self::Redo => Shortcut {
267 key: Key::Z,
268 shift: true,
269 alt: false,
270 },
271 Self::Cut => Shortcut::command(Key::X),
272 Self::Copy => Shortcut::command(Key::C),
273 Self::Paste => Shortcut::command(Key::V),
274 Self::SelectAll => Shortcut::command(Key::A),
275 }
276 }
277}
278
279#[derive(Clone, Debug, PartialEq, Eq)]
282pub struct Menu<A> {
283 pub title: String,
284 pub entries: Vec<MenuEntry<A>>,
285}
286
287#[derive(Clone, Debug, PartialEq, Eq)]
288pub enum MenuEntry<A> {
289 Item {
290 title: String,
291 shortcut: Option<Shortcut>,
292 action: A,
293 },
294 Separator,
295 Submenu(Menu<A>),
296}
297
298impl<A> Menu<A> {
299 pub fn number(self, actions: &mut Vec<A>) -> Menu<usize> {
302 Menu {
303 title: self.title,
304 entries: MenuEntry::number_all(self.entries, actions),
305 }
306 }
307}
308
309impl<A> MenuEntry<A> {
310 pub fn number_all(entries: Vec<Self>, actions: &mut Vec<A>) -> Vec<MenuEntry<usize>> {
313 entries
314 .into_iter()
315 .map(|entry| entry.number(actions))
316 .collect()
317 }
318
319 fn number(self, actions: &mut Vec<A>) -> MenuEntry<usize> {
320 match self {
321 Self::Item {
322 title,
323 shortcut,
324 action,
325 } => {
326 actions.push(action);
327 MenuEntry::Item {
328 title,
329 shortcut,
330 action: actions.len() - 1,
331 }
332 }
333 Self::Separator => MenuEntry::Separator,
334 Self::Submenu(menu) => MenuEntry::Submenu(menu.number(actions)),
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn shortcuts_read_as_code_writes_them() {
345 assert_eq!(Shortcut::parse("n"), Ok(Shortcut::command(Key::N)));
346 assert_eq!(
347 Shortcut::parse("shift+alt+return"),
348 Ok(Shortcut {
349 key: Key::Return,
350 shift: true,
351 alt: true,
352 })
353 );
354 assert_eq!(Shortcut::parse(","), Ok(Shortcut::command(Key::Comma)));
355 assert_eq!(
356 Shortcut::parse("ctrl+n"),
357 Err(ShortcutError::Modifier("ctrl".to_owned()))
358 );
359 assert_eq!(
360 Shortcut::parse("shift+shift+n"),
361 Err(ShortcutError::Repeated("shift".to_owned()))
362 );
363 assert_eq!(
364 Shortcut::parse("f1"),
365 Err(ShortcutError::Key("f1".to_owned()))
366 );
367 for &key in Key::ALL {
368 let shortcut = Shortcut {
369 key,
370 shift: true,
371 alt: false,
372 };
373 assert_eq!(Shortcut::parse(&shortcut.to_string()), Ok(shortcut));
374 }
375 }
376
377 #[test]
378 fn system_commands_keep_their_shortcuts() {
379 assert_eq!(
380 Shortcut::parse("c").ok().and_then(Shortcut::taken_by),
381 Some(SystemCommand::Copy)
382 );
383 assert_eq!(
384 Shortcut::parse("shift+c").ok().and_then(Shortcut::taken_by),
385 None
386 );
387 }
388
389 #[test]
390 fn numbering_moves_actions_out_in_order() {
391 let menu = Menu {
392 title: "File".to_owned(),
393 entries: vec![
394 MenuEntry::Item {
395 title: "New".to_owned(),
396 shortcut: None,
397 action: "new",
398 },
399 MenuEntry::Separator,
400 MenuEntry::Submenu(Menu {
401 title: "Sort".to_owned(),
402 entries: vec![MenuEntry::Item {
403 title: "By name".to_owned(),
404 shortcut: None,
405 action: "sort",
406 }],
407 }),
408 ],
409 };
410 let mut actions = Vec::new();
411 let numbered = menu.number(&mut actions);
412 assert_eq!(actions, ["new", "sort"]);
413 let MenuEntry::Submenu(sort) = &numbered.entries[2] else {
414 panic!("the third entry is a submenu");
415 };
416 assert!(matches!(sort.entries[0], MenuEntry::Item { action: 1, .. }));
417 }
418}