1use crate::error::ComputerError;
4use crate::types::Modifier;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct KeyCombo {
9 pub modifiers: Vec<Modifier>,
11 pub key: String,
13}
14
15pub fn parse_key_combo(combo: &str) -> Result<KeyCombo, ComputerError> {
20 let trimmed = combo.trim();
21 if trimmed.is_empty() {
22 return Err(ComputerError::InvalidArguments("key combo is empty".into()));
23 }
24 let parts: Vec<&str> = trimmed.split('+').map(str::trim).collect();
25 if parts.iter().any(|part| part.is_empty()) {
26 return Err(ComputerError::InvalidArguments(format!(
27 "key combo has an empty segment: {combo:?}"
28 )));
29 }
30 let (modifier_parts, key_part) = parts.split_at(parts.len() - 1);
31 let mut modifiers = Vec::new();
32 for part in modifier_parts {
33 let modifier = parse_modifier(part).ok_or_else(|| {
34 ComputerError::InvalidArguments(format!("unknown modifier {part:?} in combo {combo:?}"))
35 })?;
36 if !modifiers.contains(&modifier) {
37 modifiers.push(modifier);
38 }
39 }
40 let key = canonical_key(key_part[0]);
41 if key.is_empty() {
42 return Err(ComputerError::InvalidArguments(
43 "key combo is missing a key".into(),
44 ));
45 }
46 if !is_known_key(&key) {
47 return Err(ComputerError::InvalidArguments(format!(
48 "unknown key {key:?}"
49 )));
50 }
51 Ok(KeyCombo { modifiers, key })
52}
53
54pub fn parse_key_sequence(text: &str) -> Result<Vec<KeyCombo>, ComputerError> {
56 let trimmed = text.trim();
57 if trimmed.is_empty() {
58 return Err(ComputerError::InvalidArguments("key combo is empty".into()));
59 }
60 let mut combos = Vec::new();
61 for token in trimmed.split_whitespace() {
62 combos.push(parse_key_combo(token)?);
63 }
64 if combos.is_empty() {
65 return Err(ComputerError::InvalidArguments("key combo is empty".into()));
66 }
67 Ok(combos)
68}
69
70pub fn is_known_key(key: &str) -> bool {
72 if key.chars().count() == 1 {
73 return true;
74 }
75 let lower = key.trim().to_ascii_lowercase().replace('-', "_");
76 canonical_named(&lower).is_some()
77}
78
79pub fn macos_function_gap(key: &str) -> Result<(), ComputerError> {
81 #[cfg(target_os = "macos")]
82 if matches!(key, "f21" | "f22" | "f23" | "f24") {
83 return Err(ComputerError::Unsupported(format!(
84 "{key} has no macOS virtual keycode; F13 through F20 are available"
85 )));
86 }
87 #[cfg(not(target_os = "macos"))]
88 let _ = key;
89 Ok(())
90}
91
92fn parse_modifier(text: &str) -> Option<Modifier> {
93 match text.to_ascii_lowercase().as_str() {
94 "cmd" | "command" => Some(Modifier::Command),
95 "meta" | "super" => Some(Modifier::Meta),
96 "ctrl" | "control" => Some(Modifier::Control),
97 "alt" | "option" => Some(Modifier::Alt),
98 "shift" => Some(Modifier::Shift),
99 _ => None,
100 }
101}
102
103pub fn canonical_key(text: &str) -> String {
105 let trimmed = text.trim();
106 let lower = trimmed.to_ascii_lowercase().replace('-', "_");
107 if let Some(name) = canonical_named(&lower) {
108 return name.to_string();
109 }
110 if trimmed.chars().count() == 1 {
111 return trimmed.to_string();
112 }
113 lower
114}
115
116fn canonical_named(lower: &str) -> Option<&'static str> {
117 Some(match lower {
118 "enter" | "return" => "enter",
119 "tab" => "tab",
120 "esc" | "escape" => "esc",
121 "space" => "space",
122 "backspace" | "bksp" => "backspace",
123 "delete" | "del" => "delete",
124 "left" | "leftarrow" | "left_arrow" | "arrowleft" | "arrow_left" => "left",
125 "right" | "rightarrow" | "right_arrow" | "arrowright" | "arrow_right" => "right",
126 "up" | "uparrow" | "up_arrow" | "arrowup" | "arrow_up" => "up",
127 "down" | "downarrow" | "down_arrow" | "arrowdown" | "arrow_down" => "down",
128 "home" => "home",
129 "end" => "end",
130 "pageup" | "page_up" | "prior" => "pageup",
131 "pagedown" | "page_down" | "next" => "pagedown",
132 "insert" => "insert",
133 "plus" => "plus",
134 "minus" => "minus",
135 "f1" => "f1",
136 "f2" => "f2",
137 "f3" => "f3",
138 "f4" => "f4",
139 "f5" => "f5",
140 "f6" => "f6",
141 "f7" => "f7",
142 "f8" => "f8",
143 "f9" => "f9",
144 "f10" => "f10",
145 "f11" => "f11",
146 "f12" => "f12",
147 "f13" => "f13",
148 "f14" => "f14",
149 "f15" => "f15",
150 "f16" => "f16",
151 "f17" => "f17",
152 "f18" => "f18",
153 "f19" => "f19",
154 "f20" => "f20",
155 "f21" => "f21",
156 "f22" => "f22",
157 "f23" => "f23",
158 "f24" => "f24",
159 "shift" => "shift",
160 "ctrl" | "control" => "control",
161 "alt" | "option" => "alt",
162 "cmd" | "command" | "meta" | "super" => "meta",
163 _ => return None,
164 })
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn modifiers(combo: &str) -> (Vec<Modifier>, String) {
172 let parsed = parse_key_combo(combo).unwrap();
173 (parsed.modifiers, parsed.key)
174 }
175
176 #[test]
177 fn parses_modifiers_and_named_keys() {
178 assert_eq!(modifiers("space"), (vec![], "space".into()));
179 assert_eq!(modifiers("Enter"), (vec![], "enter".into()));
180 assert_eq!(modifiers("ESC"), (vec![], "esc".into()));
181 assert_eq!(modifiers("F12"), (vec![], "f12".into()));
182 assert_eq!(modifiers("arrow_left"), (vec![], "left".into()));
183 assert_eq!(modifiers("page_up"), (vec![], "pageup".into()));
184
185 assert_eq!(modifiers("cmd+c"), (vec![Modifier::Command], "c".into()));
186 assert_eq!(
187 modifiers("command+v"),
188 (vec![Modifier::Command], "v".into())
189 );
190 assert_eq!(modifiers("meta+a"), (vec![Modifier::Meta], "a".into()));
191 assert_eq!(modifiers("super+tab"), (vec![Modifier::Meta], "tab".into()));
192 assert_eq!(
193 modifiers("ctrl+shift+tab"),
194 (vec![Modifier::Control, Modifier::Shift], "tab".into())
195 );
196 assert_eq!(
197 modifiers("control+z"),
198 (vec![Modifier::Control], "z".into())
199 );
200 assert_eq!(modifiers("alt+f4"), (vec![Modifier::Alt], "f4".into()));
201 assert_eq!(modifiers("option+q"), (vec![Modifier::Alt], "q".into()));
202 assert_eq!(modifiers("shift+A"), (vec![Modifier::Shift], "A".into()));
203 assert_eq!(
204 modifiers(" cmd + shift + 4 "),
205 (vec![Modifier::Command, Modifier::Shift], "4".into())
206 );
207 assert_eq!(
208 modifiers("Ctrl+Alt+Delete"),
209 (vec![Modifier::Control, Modifier::Alt], "delete".into())
210 );
211 }
212
213 #[test]
214 fn dedups_repeated_modifiers() {
215 let (mods, key) = modifiers("shift+shift+a");
216 assert_eq!(mods, vec![Modifier::Shift]);
217 assert_eq!(key, "a");
218 }
219
220 #[test]
221 fn rejects_empty_unknown_and_dangling_plus() {
222 assert!(parse_key_combo("").is_err());
223 assert!(parse_key_combo(" ").is_err());
224 assert!(parse_key_combo("cmd+").is_err());
225 assert!(parse_key_combo("+c").is_err());
226 assert!(parse_key_combo("foo+c").is_err());
227 assert!(parse_key_combo("cmd++c").is_err());
228 assert!(parse_key_combo("bogus").is_err());
229 }
230
231 #[test]
232 fn xdotool_names_and_sequences() {
233 assert_eq!(modifiers("super+c"), (vec![Modifier::Meta], "c".into()));
234 assert_eq!(modifiers("Return"), (vec![], "enter".into()));
235 assert_eq!(
236 modifiers("ctrl+alt+Delete"),
237 (vec![Modifier::Control, Modifier::Alt], "delete".into())
238 );
239 assert_eq!(modifiers("plus"), (vec![], "plus".into()));
240 assert_eq!(
241 modifiers("shift+plus"),
242 (vec![Modifier::Shift], "plus".into())
243 );
244 assert_eq!(modifiers("minus"), (vec![], "minus".into()));
245 assert_eq!(modifiers("F24"), (vec![], "f24".into()));
246 assert_eq!(modifiers("Prior"), (vec![], "pageup".into()));
247 assert_eq!(modifiers("Next"), (vec![], "pagedown".into()));
248 assert_eq!(modifiers("Insert"), (vec![], "insert".into()));
249 assert_eq!(modifiers("BackSpace"), (vec![], "backspace".into()));
250 let sequence = parse_key_sequence("ctrl+a Delete").unwrap();
251 assert_eq!(sequence.len(), 2);
252 assert_eq!(sequence[0].key, "a");
253 assert_eq!(sequence[1].key, "delete");
254 assert!(parse_key_sequence("bogus").is_err());
255 #[cfg(target_os = "macos")]
256 assert_eq!(macos_function_gap("f24").unwrap_err().code(), "unsupported");
257 #[cfg(not(target_os = "macos"))]
258 assert!(macos_function_gap("f24").is_ok());
259 }
260}