Skip to main content

nifty_runtime/
menu.rs

1//! Menus and the keyboard shortcuts of their items, which every platform builds with its
2//! own menus.
3
4use std::fmt;
5
6coded_enum! {
7    /// A key a shortcut presses with the platform's command key.
8    #[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    /// How a shortcut names the key: the character it types, or a word for the others.
66    #[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    /// The character the key types without Shift, for keys that type one.
124    #[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/// A keyboard shortcut: the platform's command key, Cmd on macOS and Ctrl on Windows,
139/// with `key`, and Shift or Alt (Option on macOS) when set. `repr(C)` because compiled
140/// apps store it as is.
141#[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
149/// The modifiers a shortcut may add to the command key, as code writes them.
150pub 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    /// Reads a shortcut as code writes it: its modifiers, then its key, joined by `+`,
164    /// such as `"n"` or `"shift+n"`.
165    ///
166    /// # Errors
167    /// When a part is no modifier or key, or a modifier is written twice.
168    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    /// The system command that already uses this shortcut, if one does.
186    #[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    /// As code writes it.
197    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/// Why a shortcut can't be read.
209#[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    /// The commands the system's own menus and text fields take shortcuts for, which an
234    /// app's menus leave alone.
235    #[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/// A menu: its title, and its entries in order. `A` is what an item does: an action the
280/// app runs, or, as backends receive it, the number of the item.
281#[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    /// The same menu with each item's action moved to the end of `actions`, and the item
300    /// numbered by where it went.
301    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    /// `entries`, each item's action moved to the end of `actions` and the item numbered
311    /// by where it went.
312    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}