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 if layout.is_empty() {
107 bail!("no Oryx layout: set oryx_layout in your plan, or $VERY_NORMAL_LAYOUT");
108 }
109 let query = format!(
110 "query {{ layout(hashId: \"{layout}\", geometry: \"voyager\", revisionId: \"latest\") \
111 {{ title revision {{ hashId createdAt layers {{ title position keys }} }} }} }}"
112 );
113 let body = serde_json::json!({ "query": query }).to_string();
114 let out = Command::new("curl")
115 .args(["-sS", "--max-time", "20", "https://oryx.zsa.io/graphql"])
116 .args(["-H", "Content-Type: application/json", "-d", &body])
117 .output()
118 .context("running curl")?;
119 if !out.status.success() {
120 bail!(
121 "curl failed: {}",
122 String::from_utf8_lossy(&out.stderr).trim()
123 );
124 }
125 let v: Value =
126 serde_json::from_slice(&out.stdout).context("Oryx sent something that isn't JSON")?;
127 parse(&v)
128}
129
130fn code(key: &Value, part: &str) -> String {
131 key.get(part)
132 .and_then(|p| p.get("code"))
133 .and_then(Value::as_str)
134 .unwrap_or("")
135 .trim_start_matches("KC_")
136 .to_string()
137}
138
139fn character(key: &Value) -> char {
140 let c = code(key, "tap");
141 match c.as_str() {
142 "SCLN" => ';',
143 "COMMA" => ',',
144 "DOT" => '.',
145 "SLASH" => '/',
146 "QUOTE" => '\'',
147 s if s.len() == 1 => s.chars().next().unwrap().to_ascii_lowercase(),
148 _ => '?',
149 }
150}
151
152pub fn parse(v: &Value) -> Result<Snapshot> {
153 let layout = v
154 .pointer("/data/layout")
155 .filter(|l| !l.is_null())
156 .ok_or_else(|| anyhow!("Oryx didn't return that layout. Is its link public?"))?;
157 let layers = layout
158 .pointer("/revision/layers")
159 .and_then(Value::as_array)
160 .ok_or_else(|| anyhow!("the layout has no layers"))?;
161 let keys_of = |layer: &Value| {
162 layer
163 .get("keys")
164 .and_then(Value::as_array)
165 .cloned()
166 .unwrap_or_default()
167 };
168 let main = layers
169 .iter()
170 .find(|l| l.get("position").and_then(Value::as_u64) == Some(0))
171 .map(keys_of)
172 .ok_or_else(|| anyhow!("the layout has no main layer"))?;
173 if main.len() < 43 {
174 bail!(
175 "the main layer has {} keys; is this a Voyager layout?",
176 main.len()
177 );
178 }
179 let mirror = layers
180 .iter()
181 .find(|l| {
182 l.get("title")
183 .and_then(Value::as_str)
184 .is_some_and(|t| t.eq_ignore_ascii_case("mirror"))
185 })
186 .map(keys_of)
187 .filter(|k| k.len() >= 43)
188 .map(|k| {
189 (0..3)
190 .flat_map(|row| (0..5).map(move |col| row * 10 + col))
191 .map(|i| character(&k[SLOTS[i]]))
192 .collect()
193 });
194 let text = |p: &str| {
195 layout
196 .pointer(p)
197 .and_then(Value::as_str)
198 .unwrap_or("")
199 .to_string()
200 };
201 Ok(Snapshot {
202 title: text("/title"),
203 revision: text("/revision/hashId"),
204 created: text("/revision/createdAt"),
205 main: SLOTS.iter().map(|s| character(&main[*s])).collect(),
206 holds: expected_holds()
207 .iter()
208 .map(|(i, _)| (*i, code(&main[SLOTS[*i]], "hold")))
209 .collect(),
210 mirror,
211 })
212}
213
214impl Snapshot {
215 pub fn main_string(&self) -> String {
216 self.main.iter().collect()
217 }
218
219 pub fn stage(&self) -> Option<usize> {
221 layout::find_stage(&self.main_string())
222 }
223
224 pub fn compare(&self, stage: usize) -> Vec<Finding> {
226 let want = layout::keys(stage);
227 let mut out: Vec<Finding> = (0..30)
228 .filter(|i| self.main[*i] != want[*i])
229 .map(|index| Finding::Key {
230 index,
231 got: self.main[index],
232 want: want[index],
233 })
234 .collect();
235 for ((index, got), (_, want)) in self.holds.iter().zip(expected_holds()) {
236 if *got != want {
237 out.push(Finding::Hold {
238 index: *index,
239 got: got.clone(),
240 want,
241 });
242 }
243 }
244 if let Some(mirror) = &self.mirror {
245 for (n, got) in mirror.iter().enumerate() {
246 let index = (n / 5) * 10 + n % 5;
247 let want = want[layout::mirror(index)];
248 if *got != want {
249 out.push(Finding::Mirror {
250 index,
251 got: *got,
252 want,
253 });
254 }
255 }
256 }
257 out
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn fixture(name: &str) -> Snapshot {
266 let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
267 let v: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
268 parse(&v).unwrap()
269 }
270
271 #[test]
272 fn reads_the_planck_layout_as_stage_zero() {
273 let s = fixture("oryx-stage0.json");
274 assert_eq!(s.title, "voyager-planck");
275 assert_eq!(s.stage(), Some(0));
276 assert!(s.compare(0).is_empty());
277 }
278
279 #[test]
280 fn reads_the_very_normal_layout_as_stage_one() {
281 let s = fixture("oryx-stage1.json");
282 assert_eq!(s.stage(), Some(1));
283 assert!(s.compare(1).is_empty(), "{:?}", s.compare(1));
284 }
285
286 #[test]
287 fn lists_what_stage_two_still_needs() {
288 let s = fixture("oryx-stage1.json");
289 let f = s.compare(2);
290 let keys = f
291 .iter()
292 .filter(|f| matches!(f, Finding::Key { .. }))
293 .count();
294 let mirror = f
295 .iter()
296 .filter(|f| matches!(f, Finding::Mirror { .. }))
297 .count();
298 assert_eq!(keys, layout::changes(2).len());
299 assert_eq!(
300 mirror,
301 layout::changes(2).iter().filter(|c| c.right_hand()).count()
302 );
303 }
304
305 #[test]
306 fn a_missing_layout_is_explained() {
307 let v = serde_json::json!({"data": {"layout": null}});
308 assert!(parse(&v).unwrap_err().to_string().contains("public"));
309 }
310}