1use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use serde_json::Value;
10
11use crate::layout;
12
13pub fn layout_id() -> String {
15 std::env::var("VERY_NORMAL_LAYOUT")
16 .ok()
17 .or_else(|| layout::plan().oryx_layout.clone())
18 .unwrap_or_default()
19}
20
21pub fn edit_url(layout: &str) -> String {
22 format!("https://configure.zsa.io/voyager/layouts/{layout}/latest/0")
23}
24
25const SLOTS: [usize; 30] = [
29 1, 2, 3, 4, 5, 26, 27, 28, 29, 30, 7, 8, 9, 10, 11, 32, 33, 34, 35, 36, 13, 14, 15, 16, 17, 38, 39, 40, 41, 42,
32];
33
34fn expected_holds() -> Vec<(usize, String)> {
36 (10..20)
37 .filter_map(|i| {
38 let side = if i % 10 < 5 { "LEFT" } else { "RIGHT" };
39 let code = match layout::modifier(i)? {
40 "ctrl" => "CTRL",
41 "alt" => "ALT",
42 "cmd" => "GUI",
43 _ => "SHIFT",
44 };
45 Some((i, format!("{side}_{code}")))
46 })
47 .collect()
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct Snapshot {
52 pub title: String,
53 pub revision: String,
54 pub created: String,
55 pub main: Vec<char>,
57 pub holds: Vec<(usize, String)>,
59 pub mirror: Option<Vec<char>>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
64pub enum Finding {
65 Key {
66 index: usize,
67 got: char,
68 want: char,
69 },
70 Hold {
71 index: usize,
72 got: String,
73 want: String,
74 },
75 Mirror {
76 index: usize,
77 got: char,
78 want: char,
79 },
80}
81
82impl std::fmt::Display for Finding {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Finding::Key { index, got, want } => write!(
86 f,
87 "main layer: the key that started as {} types {got}, should type {want}",
88 layout::keys(0)[*index]
89 ),
90 Finding::Hold { index, got, want } => write!(
91 f,
92 "home row mod: the key that started as {} holds {}, should hold {want}",
93 layout::keys(0)[*index],
94 if got.is_empty() { "nothing" } else { got }
95 ),
96 Finding::Mirror { index, got, want } => write!(
97 f,
98 "mirror layer: the left key that started as {} types {got}, should type {want}",
99 layout::keys(0)[*index]
100 ),
101 }
102 }
103}
104
105pub fn fetch(layout: &str) -> Result<Snapshot> {
106 parse(&query(layout)?)
107}
108
109pub fn home_row_mods(layout: &str) -> Result<Vec<String>> {
112 holds_of(&query(layout)?)
113}
114
115fn holds_of(v: &Value) -> Result<Vec<String>> {
116 let main = main_layer(v)?;
117 Ok((10..20)
118 .map(|i| {
119 let hold = code(&main[SLOTS[i]], "hold");
120 let m = if hold.ends_with("CTRL") {
121 "ctrl"
122 } else if hold.ends_with("ALT") {
123 "alt"
124 } else if hold.ends_with("GUI") {
125 "cmd"
126 } else if hold.ends_with("SHIFT") {
127 "shift"
128 } else {
129 ""
130 };
131 m.to_string()
132 })
133 .collect())
134}
135
136fn query(layout: &str) -> Result<Value> {
137 if layout.is_empty() {
138 bail!("no Oryx layout: set oryx_layout in your plan, or $VERY_NORMAL_LAYOUT");
139 }
140 let query = format!(
141 "query {{ layout(hashId: \"{layout}\", geometry: \"voyager\", revisionId: \"latest\") \
142 {{ title revision {{ hashId createdAt layers {{ title position keys }} }} }} }}"
143 );
144 let body = serde_json::json!({ "query": query }).to_string();
145 let out = Command::new("curl")
146 .args(["-sS", "--max-time", "20", "https://oryx.zsa.io/graphql"])
147 .args(["-H", "Content-Type: application/json", "-d", &body])
148 .output()
149 .context("running curl")?;
150 if !out.status.success() {
151 bail!(
152 "curl failed: {}",
153 String::from_utf8_lossy(&out.stderr).trim()
154 );
155 }
156 serde_json::from_slice(&out.stdout).context("Oryx sent something that isn't JSON")
157}
158
159fn layout_of(v: &Value) -> Result<&Value> {
160 v.pointer("/data/layout")
161 .filter(|l| !l.is_null())
162 .ok_or_else(|| anyhow!("Oryx didn't return that layout. Is its link public?"))
163}
164
165fn keys_of(layer: &Value) -> Vec<Value> {
166 layer
167 .get("keys")
168 .and_then(Value::as_array)
169 .cloned()
170 .unwrap_or_default()
171}
172
173fn layers_of(v: &Value) -> Result<&Vec<Value>> {
174 layout_of(v)?
175 .pointer("/revision/layers")
176 .and_then(Value::as_array)
177 .ok_or_else(|| anyhow!("the layout has no layers"))
178}
179
180fn main_layer(v: &Value) -> Result<Vec<Value>> {
181 let main = layers_of(v)?
182 .iter()
183 .find(|l| l.get("position").and_then(Value::as_u64) == Some(0))
184 .map(keys_of)
185 .ok_or_else(|| anyhow!("the layout has no main layer"))?;
186 if main.len() < 43 {
187 bail!(
188 "the main layer has {} keys; is this a Voyager layout?",
189 main.len()
190 );
191 }
192 Ok(main)
193}
194
195fn code(key: &Value, part: &str) -> String {
196 key.get(part)
197 .and_then(|p| p.get("code"))
198 .and_then(Value::as_str)
199 .unwrap_or("")
200 .trim_start_matches("KC_")
201 .to_string()
202}
203
204fn character(key: &Value) -> char {
205 let c = code(key, "tap");
206 match c.as_str() {
207 "SCLN" => ';',
208 "COMMA" => ',',
209 "DOT" => '.',
210 "SLASH" => '/',
211 "QUOTE" => '\'',
212 s if s.len() == 1 => s.chars().next().unwrap().to_ascii_lowercase(),
213 _ => '?',
214 }
215}
216
217pub fn parse(v: &Value) -> Result<Snapshot> {
218 let layout = layout_of(v)?;
219 let layers = layers_of(v)?;
220 let main = main_layer(v)?;
221 let mirror = layers
222 .iter()
223 .find(|l| {
224 l.get("title")
225 .and_then(Value::as_str)
226 .is_some_and(|t| t.eq_ignore_ascii_case("mirror"))
227 })
228 .map(keys_of)
229 .filter(|k| k.len() >= 43)
230 .map(|k| {
231 (0..3)
232 .flat_map(|row| (0..5).map(move |col| row * 10 + col))
233 .map(|i| character(&k[SLOTS[i]]))
234 .collect()
235 });
236 let text = |p: &str| {
237 layout
238 .pointer(p)
239 .and_then(Value::as_str)
240 .unwrap_or("")
241 .to_string()
242 };
243 Ok(Snapshot {
244 title: text("/title"),
245 revision: text("/revision/hashId"),
246 created: text("/revision/createdAt"),
247 main: SLOTS.iter().map(|s| character(&main[*s])).collect(),
248 holds: expected_holds()
249 .iter()
250 .map(|(i, _)| (*i, code(&main[SLOTS[*i]], "hold")))
251 .collect(),
252 mirror,
253 })
254}
255
256impl Snapshot {
257 pub fn main_string(&self) -> String {
258 self.main.iter().collect()
259 }
260
261 pub fn stage(&self) -> Option<usize> {
263 layout::find_stage(&self.main_string())
264 }
265
266 pub fn compare(&self, stage: usize) -> Vec<Finding> {
268 let want = layout::keys(stage);
269 let mut out: Vec<Finding> = (0..30)
270 .filter(|i| self.main[*i] != want[*i])
271 .map(|index| Finding::Key {
272 index,
273 got: self.main[index],
274 want: want[index],
275 })
276 .collect();
277 for ((index, got), (_, want)) in self.holds.iter().zip(expected_holds()) {
278 if *got != want {
279 out.push(Finding::Hold {
280 index: *index,
281 got: got.clone(),
282 want,
283 });
284 }
285 }
286 if let Some(mirror) = &self.mirror {
287 for (n, got) in mirror.iter().enumerate() {
288 let index = (n / 5) * 10 + n % 5;
289 let want = want[layout::mirror(index)];
290 if *got != want {
291 out.push(Finding::Mirror {
292 index,
293 got: *got,
294 want,
295 });
296 }
297 }
298 }
299 out
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn fixture(name: &str) -> Snapshot {
308 let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
309 let v: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
310 parse(&v).unwrap()
311 }
312
313 #[test]
314 fn reads_the_planck_layout_as_stage_zero() {
315 let s = fixture("oryx-stage0.json");
316 assert_eq!(s.title, "voyager-planck");
317 assert_eq!(s.stage(), Some(0));
318 assert!(s.compare(0).is_empty());
319 }
320
321 #[test]
322 fn reads_the_very_normal_layout_as_stage_one() {
323 let s = fixture("oryx-stage1.json");
324 assert_eq!(s.stage(), Some(1));
325 assert!(s.compare(1).is_empty(), "{:?}", s.compare(1));
326 }
327
328 #[test]
329 fn lists_what_stage_two_still_needs() {
330 let s = fixture("oryx-stage1.json");
331 let f = s.compare(2);
332 let keys = f
333 .iter()
334 .filter(|f| matches!(f, Finding::Key { .. }))
335 .count();
336 let mirror = f
337 .iter()
338 .filter(|f| matches!(f, Finding::Mirror { .. }))
339 .count();
340 assert_eq!(keys, layout::changes(2).len());
341 assert_eq!(
342 mirror,
343 layout::changes(2).iter().filter(|c| c.right_hand()).count()
344 );
345 }
346
347 #[test]
348 fn reads_home_row_mods_for_a_new_plan() {
349 let path = format!(
350 "{}/tests/fixtures/oryx-stage1.json",
351 env!("CARGO_MANIFEST_DIR")
352 );
353 let v: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
354 assert_eq!(
355 holds_of(&v).unwrap(),
356 ["", "ctrl", "alt", "cmd", "", "", "cmd", "alt", "ctrl", ""]
357 );
358 }
359
360 #[test]
361 fn a_missing_layout_is_explained() {
362 let v = serde_json::json!({"data": {"layout": null}});
363 assert!(parse(&v).unwrap_err().to_string().contains("public"));
364 }
365}