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    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    /// The stage the main layer matches, if it matches one exactly.
220    pub fn stage(&self) -> Option<usize> {
221        layout::find_stage(&self.main_string())
222    }
223
224    /// Everything that differs from the given stage.
225    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}