Skip to main content

pixelactions_core/
chord.rs

1//! Reading a key chord: `cmd+shift+s` → modifiers, then the key.
2//!
3//! This is pure string work, so it lives here rather than beside the
4//! injector that consumes it. Keeping it in the binary meant it was
5//! reachable only from a macOS-gated module, which made it dead code on
6//! every other platform — the sort of thing a Mac-only workflow never
7//! notices and a cross-platform CI leg catches immediately.
8
9/// Why a chord could not be read.
10#[derive(Debug, thiserror::Error, PartialEq, Eq)]
11pub enum ChordError {
12    #[error("chord {0:?} names no key — expected something like \"cmd+s\" or \"enter\"")]
13    Empty(String),
14}
15
16/// Every name a chord may use for a key that is not a single character.
17///
18/// Listed here rather than beside an injector because the names are a
19/// promise to whoever writes the flow file, and that promise cannot depend
20/// on which platform reads it: `cmd+s` written on a Mac has to mean
21/// Super+s on Linux. Each platform maps these to its own keys — enigo keys
22/// on macOS and X11, keysyms on Wayland — and both sides carry a test that
23/// every name here resolves, so a name cannot silently work on one
24/// platform and fail on another.
25///
26/// Aliases are deliberate and listed explicitly: the same physical key is
27/// called different things by different people, and refusing `option`
28/// because a Linux keyboard says `alt` would be pedantry.
29pub const NAMED_KEYS: &[&str] = &[
30    "cmd",
31    "command",
32    "meta",
33    "super",
34    "ctrl",
35    "control",
36    "alt",
37    "option",
38    "opt",
39    "shift",
40    "tab",
41    "enter",
42    "return",
43    "esc",
44    "escape",
45    "space",
46    "backspace",
47    "delete",
48    "up",
49    "down",
50    "left",
51    "right",
52];
53
54/// Split a chord into its modifiers and its final key: `cmd+shift+s` →
55/// `(["cmd", "shift"], "s")`.
56///
57/// Whitespace around the parts is tolerated because chords are
58/// hand-written in flow files, where `cmd + s` is a reasonable thing to
59/// type. An empty chord is an error rather than a no-op: a step that
60/// presses nothing is a typo, not an instruction.
61pub fn split(chord: &str) -> Result<(Vec<&str>, &str), ChordError> {
62    let mut parts: Vec<&str> = chord
63        .split('+')
64        .map(str::trim)
65        .filter(|part| !part.is_empty())
66        .collect();
67    let key = parts
68        .pop()
69        .ok_or_else(|| ChordError::Empty(chord.to_string()))?;
70    Ok((parts, key))
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn chords_split_into_modifiers_and_a_key() {
79        let (modifiers, key) = split("cmd+shift+s").expect("valid");
80        assert_eq!(modifiers, vec!["cmd", "shift"]);
81        assert_eq!(key, "s");
82    }
83
84    #[test]
85    fn a_bare_key_has_no_modifiers() {
86        let (modifiers, key) = split("enter").expect("valid");
87        assert!(modifiers.is_empty());
88        assert_eq!(key, "enter");
89    }
90
91    #[test]
92    fn whitespace_around_chord_parts_is_tolerated() {
93        let (modifiers, key) = split("cmd + s").expect("valid");
94        assert_eq!(modifiers, vec!["cmd"]);
95        assert_eq!(key, "s");
96    }
97
98    #[test]
99    fn an_empty_chord_is_an_error() {
100        assert!(split("").is_err());
101        assert!(split("+").is_err());
102        assert!(split("  ").is_err());
103    }
104
105    #[test]
106    fn the_error_quotes_the_chord_it_could_not_read() {
107        let error = split("+").expect_err("empty");
108        assert!(error.to_string().contains("\"+\""), "{error}");
109    }
110
111    /// The list is matched against lowercased tokens and printed in error
112    /// messages, so an entry with a capital or a duplicate would be a name
113    /// no chord can ever reach.
114    #[test]
115    fn every_named_key_is_lowercase_and_listed_once() {
116        for name in NAMED_KEYS {
117            assert_eq!(*name, name.to_ascii_lowercase(), "{name}");
118            assert!(!name.is_empty());
119            assert_eq!(
120                NAMED_KEYS.iter().filter(|other| *other == name).count(),
121                1,
122                "{name} is listed more than once"
123            );
124        }
125    }
126
127    /// A named key is a whole token, so none of them may contain the
128    /// separator a chord splits on.
129    #[test]
130    fn no_named_key_contains_the_separator() {
131        for name in NAMED_KEYS {
132            let (modifiers, key) = split(name).expect("a bare name is a valid chord");
133            assert!(modifiers.is_empty(), "{name}");
134            assert_eq!(key, *name);
135        }
136    }
137}