Skip to main content

blockworx_canvas2d/input/
chord.rs

1//! A keystroke as the command registry names it.
2
3use blockworx_paint::{Chord, Key, Modifiers};
4use web_sys::KeyboardEvent;
5
6/// The chord `event` presses, or `None` for a keystroke nothing binds.
7#[must_use]
8pub fn chord_of(event: &KeyboardEvent) -> Option<Chord> {
9    let pressed = event.key();
10    let held = Held {
11        // ⌘ on a Mac and Ctrl elsewhere are one modifier to a binding table,
12        // and the browser reports them as two.
13        command: event.ctrl_key() || event.meta_key(),
14        shift: event.shift_key() && shift_is_a_modifier_of(&pressed),
15    };
16    Some(Chord {
17        modifiers: modifiers_of(held)?,
18        key: key_of(&pressed)?,
19    })
20}
21
22/// What the browser says is down beside the key.
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24struct Held {
25    command: bool,
26    shift: bool,
27}
28
29/// Shift is a modifier of a letter, which arrives as the same letter either
30/// way, and spent on anything else: a shifted `=` arrives as `+`, its own key.
31fn shift_is_a_modifier_of(pressed: &str) -> bool {
32    pressed.chars().all(|c| c.is_ascii_alphabetic())
33}
34
35/// Which bound modifier set is held, or `None` for one the table does not
36/// bind at all: Shift without the command modifier binds nothing, so a shifted
37/// letter is left to the browser rather than read as the bare letter.
38fn modifiers_of(held: Held) -> Option<Modifiers> {
39    match held {
40        Held {
41            command: false,
42            shift: false,
43        } => Some(Modifiers::None),
44        Held {
45            command: false,
46            shift: true,
47        } => None,
48        Held {
49            command: true,
50            shift: false,
51        } => Some(Modifiers::Command),
52        Held {
53            command: true,
54            shift: true,
55        } => Some(Modifiers::CommandShift),
56    }
57}
58
59/// A `KeyboardEvent.key` value as a bindable key. The value is what the
60/// layout *produced*, so a shifted digit arrives as its symbol and a letter
61/// in either case.
62fn key_of(pressed: &str) -> Option<Key> {
63    Some(match pressed {
64        "ArrowLeft" => Key::ArrowLeft,
65        "ArrowRight" => Key::ArrowRight,
66        "ArrowUp" => Key::ArrowUp,
67        "ArrowDown" => Key::ArrowDown,
68        _ => return character_key(pressed),
69    })
70}
71
72fn character_key(pressed: &str) -> Option<Key> {
73    let mut chars = pressed.chars();
74    let (only, rest) = (chars.next()?, chars.next());
75    if rest.is_some() {
76        return None;
77    }
78    Some(match only.to_ascii_lowercase() {
79        '0' => Key::Num0,
80        '1' => Key::Num1,
81        '2' => Key::Num2,
82        '3' => Key::Num3,
83        '4' => Key::Num4,
84        '5' => Key::Num5,
85        '6' => Key::Num6,
86        '7' => Key::Num7,
87        '8' => Key::Num8,
88        'b' => Key::B,
89        'e' => Key::E,
90        'i' => Key::I,
91        'k' => Key::K,
92        'm' => Key::M,
93        'n' => Key::N,
94        'p' => Key::P,
95        'r' => Key::R,
96        't' => Key::T,
97        'y' => Key::Y,
98        'z' => Key::Z,
99        '=' => Key::Equals,
100        '+' => Key::Plus,
101        '-' => Key::Minus,
102        _ => return None,
103    })
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn a_letter_binds_in_either_case() {
112        assert_eq!(key_of("b"), Some(Key::B));
113        assert_eq!(key_of("B"), Some(Key::B));
114        assert_eq!(key_of("T"), Some(Key::T));
115    }
116
117    /// The zoom keys are three different keystrokes on three keyboards, and
118    /// each is bound on its own.
119    #[test]
120    fn the_zoom_keys_are_their_own_keys() {
121        assert_eq!(key_of("="), Some(Key::Equals));
122        assert_eq!(key_of("+"), Some(Key::Plus));
123        assert_eq!(key_of("-"), Some(Key::Minus));
124    }
125
126    /// `KeyboardEvent.key` names a named key by a whole word, which binds
127    /// nothing — a one-character reading of "Escape" would be `Key::E`.
128    #[test]
129    fn a_named_key_is_not_read_as_its_first_letter() {
130        assert_eq!(key_of("Escape"), None);
131        assert_eq!(key_of("Enter"), None);
132        assert_eq!(key_of("Backspace"), None);
133        assert_eq!(key_of(""), None);
134    }
135
136    #[test]
137    fn the_arrows_are_read_by_their_names() {
138        assert_eq!(key_of("ArrowLeft"), Some(Key::ArrowLeft));
139        assert_eq!(key_of("ArrowRight"), Some(Key::ArrowRight));
140        assert_eq!(key_of("ArrowUp"), Some(Key::ArrowUp));
141        assert_eq!(key_of("ArrowDown"), Some(Key::ArrowDown));
142    }
143
144    #[test]
145    fn an_unbound_key_names_no_chord() {
146        assert_eq!(key_of("q"), None);
147        assert_eq!(key_of("9"), None);
148    }
149
150    /// Shift held over a letter is a modifier; over a symbol it was spent
151    /// producing the symbol, and over nothing else it binds nothing.
152    #[test]
153    fn shift_is_a_modifier_of_a_letter_only() {
154        assert!(shift_is_a_modifier_of("z"));
155        assert!(shift_is_a_modifier_of("Z"));
156        assert!(!shift_is_a_modifier_of("+"));
157        assert!(!shift_is_a_modifier_of("2"));
158        let held = |command, shift| modifiers_of(Held { command, shift });
159        assert_eq!(held(true, true), Some(Modifiers::CommandShift));
160        assert_eq!(held(true, false), Some(Modifiers::Command));
161        assert_eq!(held(false, false), Some(Modifiers::None));
162        assert_eq!(held(false, true), None);
163    }
164}