Skip to main content

very_normal/
oryx.rs

1//! Reading a layout from Oryx, ZSA's configurator, to check a stage landed.
2//!
3//! Oryx has no documented API; this uses the GraphQL endpoint its own page
4//! loads layouts from. It only works for layouts with a public link.
5
6use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use serde_json::Value;
10
11use crate::layout;
12
13/// `$VERY_NORMAL_LAYOUT`, or the plan's `oryx_layout`; empty if neither is set.
14pub 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
25/// Where each of the 30 letter positions sits in Oryx's key list for the
26/// Voyager: the left half's rows of six (outer column first), then the
27/// right half's rows of six (outer column last).
28const SLOTS: [usize; 30] = [
29    1, 2, 3, 4, 5, 26, 27, 28, 29, 30, //
30    7, 8, 9, 10, 11, 32, 33, 34, 35, 36, //
31    13, 14, 15, 16, 17, 38, 39, 40, 41, 42,
32];
33
34/// The Oryx hold codes the plan's home row mods should have.
35fn 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    /// The main layer's 30 letter keys; `?` where a key isn't a plain character.
56    pub main: Vec<char>,
57    /// What the six home row mod keys do when held.
58    pub holds: Vec<(usize, String)>,
59    /// The mirror layer's 15 left-hand letter keys, if there is a mirror layer.
60    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
109/// What each of the ten home row keys does when held, as a plan's
110/// `home_row_mods` ("ctrl", "alt", "cmd", "shift" or "").
111pub 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    /// The stage the main layer matches, if it matches one exactly.
262    pub fn stage(&self) -> Option<usize> {
263        layout::find_stage(&self.main_string())
264    }
265
266    /// Everything that differs from the given stage.
267    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}