Skip to main content

blockworx_web/
chords.rs

1//! How a keystroke is written where the page is open.
2//!
3//! The binding table is one table on every host
4//! ([`BINDINGS`](blockworx_tools::commands::BINDINGS)); what differs
5//! is the token the command modifier is written as, which
6//! `docs/cad-ui-spec.md` §4 names as the whole of the difference. Reading the
7//! platform is the only browser-bound part, so the spelling itself is a pure
8//! function and is tested as one.
9
10use blockworx_paint::{Chord, Key, Modifiers};
11use blockworx_tools::commands::{CommandId, chords};
12use web_sys::Window;
13
14/// How the command modifier is written here.
15#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
16pub enum Token {
17    /// A Mac keyboard, where the modifier is ⌘.
18    Command,
19    #[default]
20    Control,
21}
22
23impl Token {
24    /// What `window` is running on. A browser that will not say is written
25    /// the way most of them are.
26    #[must_use]
27    pub fn of(window: &Window) -> Self {
28        let agent = window.navigator().user_agent().unwrap_or_default();
29        if agent.contains("Mac") || agent.contains("iPhone") || agent.contains("iPad") {
30            Self::Command
31        } else {
32            Self::Control
33        }
34    }
35
36    fn held(self) -> &'static str {
37        match self {
38            Token::Command => "\u{2318}",
39            Token::Control => "Ctrl+",
40        }
41    }
42
43    fn shifted(self) -> &'static str {
44        match self {
45            Token::Command => "\u{2318}\u{21e7}",
46            Token::Control => "Ctrl+Shift+",
47        }
48    }
49}
50
51/// `chord` as a user would write it down.
52#[must_use]
53pub fn spelled(token: Token, chord: Chord) -> String {
54    let held = match chord.modifiers {
55        Modifiers::None => "",
56        Modifiers::Command => token.held(),
57        Modifiers::CommandShift => token.shifted(),
58    };
59    format!("{held}{}", pressed(chord.key))
60}
61
62/// A control's whole hover text: what it does, then every key that does it —
63/// in table order, so a tool that answers to two keys advertises both.
64#[must_use]
65pub fn hinted(token: Token, says: &str, id: CommandId) -> String {
66    let keys: Vec<String> = chords(id).map(|chord| spelled(token, *chord)).collect();
67    if keys.is_empty() {
68        says.to_owned()
69    } else {
70        format!("{says} ({})", keys.join(", "))
71    }
72}
73
74fn pressed(key: Key) -> &'static str {
75    match key {
76        Key::Num0 => "0",
77        Key::Num1 => "1",
78        Key::Num2 => "2",
79        Key::Num3 => "3",
80        Key::Num4 => "4",
81        Key::Num5 => "5",
82        Key::Num6 => "6",
83        Key::Num7 => "7",
84        Key::Num8 => "8",
85        Key::B => "B",
86        Key::E => "E",
87        Key::I => "I",
88        Key::K => "K",
89        Key::M => "M",
90        Key::N => "N",
91        Key::P => "P",
92        Key::R => "R",
93        Key::T => "T",
94        Key::Y => "Y",
95        Key::Z => "Z",
96        Key::Equals => "=",
97        Key::Plus => "+",
98        Key::Minus => "-",
99        Key::ArrowLeft => "\u{2190}",
100        Key::ArrowRight => "\u{2192}",
101        Key::ArrowUp => "\u{2191}",
102        Key::ArrowDown => "\u{2193}",
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use blockworx_tools::names::ToolName;
109
110    use super::*;
111
112    fn chord(modifiers: Modifiers, key: Key) -> Chord {
113        Chord { modifiers, key }
114    }
115
116    #[test]
117    fn a_bare_key_is_written_as_itself() {
118        assert_eq!(
119            spelled(Token::Control, chord(Modifiers::None, Key::Num3)),
120            "3",
121        );
122    }
123
124    /// The one difference §4 allows: the same table, two tokens.
125    #[test]
126    fn the_modifier_is_written_the_way_the_platform_writes_it() {
127        let undo = chord(Modifiers::Command, Key::Z);
128        assert_eq!(spelled(Token::Control, undo), "Ctrl+Z");
129        assert_eq!(spelled(Token::Command, undo), "\u{2318}Z");
130        let redo = chord(Modifiers::CommandShift, Key::Z);
131        assert_eq!(spelled(Token::Control, redo), "Ctrl+Shift+Z");
132        assert_eq!(spelled(Token::Command, redo), "\u{2318}\u{21e7}Z");
133    }
134
135    /// A control spells every key bound to it, so the tooltip and the table
136    /// cannot disagree about what will work.
137    #[test]
138    fn a_hint_names_every_key_the_table_binds() {
139        let hint = hinted(
140            Token::Control,
141            "New Block",
142            CommandId::Arm(ToolName::NewBlock),
143        );
144        assert_eq!(hint, "New Block (2, Ctrl+B)");
145        assert_eq!(
146            hinted(Token::Control, "Redo", CommandId::Redo),
147            "Redo (Ctrl+Shift+Z, Ctrl+Y)",
148        );
149    }
150
151    /// A command the table binds nothing to is still named; it just promises
152    /// no key.
153    #[test]
154    fn an_unbound_command_is_named_without_a_key() {
155        assert_eq!(
156            hinted(Token::Control, "Import\u{2026}", CommandId::Import),
157            "Import\u{2026}",
158        );
159    }
160}