Skip to main content

very_normal/
layout.rs

1//! The plan: the ladder of stages from where you start to your own layout.
2//!
3//! A plan is a TOML file (see `examples/very-normal.toml`). Positions run
4//! left to right, top row then home row then bottom row, ten keys a row (five
5//! per hand). Only letters and `; , . / '` live here; the outer columns never
6//! move.
7
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10
11use anyhow::{Context, Result, bail};
12use serde::Deserialize;
13
14/// Mark's plan, used when you haven't made your own.
15pub const EXAMPLE: &str = include_str!("../examples/very-normal.toml");
16
17pub const OUTER_LEFT: [&str; 3] = ["tab", "esc", "shf"];
18pub const OUTER_RIGHT: [&str; 3] = ["bsp", "'", "ent"];
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct Plan {
22    pub name: String,
23    #[serde(default)]
24    pub oryx_layout: Option<String>,
25    /// What each home row key does when held, left to right.
26    #[serde(default)]
27    pub home_row_mods: Vec<String>,
28    #[serde(default)]
29    pub common_words: Vec<String>,
30    #[serde(rename = "stage")]
31    pub stages: Vec<Stage>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35pub struct Stage {
36    pub name: String,
37    #[serde(default)]
38    pub blurb: String,
39    pub keys: String,
40    /// Share of your typing on the home row at this stage.
41    #[serde(default)]
42    pub home: f64,
43    /// Share of letter pairs typed with the same finger at this stage.
44    #[serde(default)]
45    pub same_finger: f64,
46    #[serde(default)]
47    pub vim: Vec<String>,
48    #[serde(default)]
49    pub words: Vec<String>,
50}
51
52impl Plan {
53    pub fn parse(text: &str) -> Result<Plan> {
54        let mut plan: Plan = toml::from_str(text)?;
55        for stage in &mut plan.stages {
56            stage.keys.retain(|c| !c.is_whitespace());
57        }
58        plan.validate()?;
59        Ok(plan)
60    }
61
62    pub fn load(path: &Path) -> Result<Plan> {
63        let text =
64            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
65        Plan::parse(&text).with_context(|| format!("in {}", path.display()))
66    }
67
68    fn validate(&self) -> Result<()> {
69        if self.stages.len() < 2 {
70            bail!("a plan needs at least two stages: where you start and where you're going");
71        }
72        let mut start: Vec<char> = self.stages[0].keys.chars().collect();
73        start.sort_unstable();
74        for (i, stage) in self.stages.iter().enumerate() {
75            let mut k: Vec<char> = stage.keys.chars().collect();
76            if k.len() != 30 {
77                bail!(
78                    "stage {i} has {} keys; every stage has 30 (three rows of ten)",
79                    k.len()
80                );
81            }
82            k.sort_unstable();
83            if k != start {
84                bail!("stage {i} isn't a rearrangement of stage 0's keys");
85            }
86            if i > 0 && stage.keys == self.stages[i - 1].keys {
87                bail!("stage {i} doesn't move any keys");
88            }
89        }
90        if !self.home_row_mods.is_empty() && self.home_row_mods.len() != 10 {
91            bail!(
92                "home_row_mods has {} entries; it needs 10, one per home row key",
93                self.home_row_mods.len()
94            );
95        }
96        if self.common_words.len() < 50 {
97            bail!(
98                "common_words needs at least 50 words for the drills; it has {}",
99                self.common_words.len()
100            );
101        }
102        for m in &self.home_row_mods {
103            if !matches!(m.as_str(), "" | "ctrl" | "alt" | "cmd" | "shift") {
104                bail!("home_row_mods can hold ctrl, alt, cmd, shift or \"\", not {m:?}");
105            }
106        }
107        Ok(())
108    }
109}
110
111/// `$VERY_NORMAL_PLAN`, or `~/.config/very-normal/plan.toml`.
112pub fn plan_path() -> PathBuf {
113    if let Some(p) = std::env::var_os("VERY_NORMAL_PLAN") {
114        return PathBuf::from(p);
115    }
116    let home = std::env::var_os("HOME")
117        .map(PathBuf::from)
118        .unwrap_or_default();
119    home.join(".config/very-normal/plan.toml")
120}
121
122static PLAN: OnceLock<Plan> = OnceLock::new();
123
124/// Loads your plan if you have one, otherwise Mark's. Call once at startup;
125/// says which file it used, or `None` for the built-in example.
126pub fn install() -> Result<Option<PathBuf>> {
127    let path = plan_path();
128    let (plan, used) = if path.exists() {
129        (Plan::load(&path)?, Some(path))
130    } else {
131        (example(), None)
132    };
133    let _ = PLAN.set(plan);
134    Ok(used)
135}
136
137fn example() -> Plan {
138    Plan::parse(EXAMPLE).expect("the example plan is valid")
139}
140
141pub fn plan() -> &'static Plan {
142    PLAN.get_or_init(example)
143}
144
145pub fn last() -> usize {
146    plan().stages.len() - 1
147}
148
149fn stage(s: usize) -> &'static Stage {
150    &plan().stages[s.min(last())]
151}
152
153pub fn name(s: usize) -> &'static str {
154    &stage(s).name
155}
156
157pub fn blurb(s: usize) -> &'static str {
158    &stage(s).blurb
159}
160
161/// Home row share and same-finger pairs, both as percentages.
162pub fn stats(s: usize) -> (f64, f64) {
163    (stage(s).home, stage(s).same_finger)
164}
165
166pub fn vim(s: usize) -> &'static [String] {
167    &stage(s).vim
168}
169
170pub fn words(s: usize) -> &'static [String] {
171    &stage(s).words
172}
173
174pub fn common_words() -> &'static [String] {
175    &plan().common_words
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct Change {
180    pub index: usize,
181    pub from: char,
182    pub to: char,
183}
184
185impl Change {
186    pub fn right_hand(&self) -> bool {
187        self.index % 10 >= 5
188    }
189}
190
191pub fn keys(s: usize) -> Vec<char> {
192    stage(s).keys.chars().collect()
193}
194
195pub fn keys_string(s: usize) -> &'static str {
196    &stage(s).keys
197}
198
199/// The keys that change when you climb from `stage - 1` to `stage`.
200pub fn changes(s: usize) -> Vec<Change> {
201    if s == 0 || s > last() {
202        return Vec::new();
203    }
204    let before = keys(s - 1);
205    keys(s)
206        .into_iter()
207        .enumerate()
208        .filter(|(i, c)| before[*i] != *c)
209        .map(|(index, to)| Change {
210            index,
211            from: before[index],
212            to,
213        })
214        .collect()
215}
216
217/// The characters a stage puts somewhere new, in key order.
218pub fn focus(s: usize) -> Vec<char> {
219    let mut out = Vec::new();
220    for c in changes(s) {
221        if !out.contains(&c.to) {
222            out.push(c.to);
223        }
224    }
225    out
226}
227
228/// Whether a position types something different from stage 0.
229pub fn moved(s: usize, index: usize) -> bool {
230    keys(s)[index] != keys(0)[index]
231}
232
233pub fn changed_now(s: usize, index: usize) -> bool {
234    changes(s).iter().any(|c| c.index == index)
235}
236
237/// What a key does when held, for home row positions 10 to 19.
238pub fn modifier(index: usize) -> Option<&'static str> {
239    let m = plan().home_row_mods.get(index.checked_sub(10)?)?;
240    (!m.is_empty()).then_some(m.as_str())
241}
242
243pub fn position(s: usize, ch: char) -> Option<usize> {
244    keys(s).iter().position(|c| *c == ch)
245}
246
247/// The left-hand position that mirrors a right-hand one, and the reverse.
248pub fn mirror(index: usize) -> usize {
249    let (row, col) = (index / 10, index % 10);
250    row * 10 + (9 - col)
251}
252
253/// Which stage a main layer's 30 letter keys match, if any.
254pub fn find_stage(main: &str) -> Option<usize> {
255    plan().stages.iter().position(|s| s.keys == main)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn the_example_is_marks_very_normal() {
264        let p = plan();
265        assert_eq!(p.name, "Very Normal");
266        assert_eq!(last(), 9);
267        assert_eq!(keys_string(0), "qwertyuiopasdfghjkl;zxcvbnm,./");
268        assert_eq!(keys_string(last()), "qwydgbhaujilstmpnoerzxcvk;f,./");
269        assert_eq!(modifier(13), Some("cmd"));
270        assert_eq!(modifier(10), None);
271        assert_eq!(modifier(3), None);
272    }
273
274    #[test]
275    fn every_stage_moves_a_few_keys_and_keeps_the_shortcuts() {
276        for s in 1..=last() {
277            let n = changes(s).len();
278            assert!((3..=5).contains(&n), "stage {s} moves {n}");
279            for c in "qwzxcv".chars() {
280                assert_eq!(position(s, c), position(0, c), "{c} moved in stage {s}");
281            }
282        }
283    }
284
285    #[test]
286    fn stage_one_swaps_e_l_t_f() {
287        let got: Vec<(char, char)> = changes(1).iter().map(|c| (c.from, c.to)).collect();
288        assert_eq!(got, vec![('e', 'l'), ('t', 'f'), ('f', 't'), ('l', 'e')]);
289        assert_eq!(focus(1), vec!['l', 'f', 't', 'e']);
290    }
291
292    #[test]
293    fn mirror_reflects_across_the_hands() {
294        assert_eq!(mirror(18), 11);
295        assert_eq!(mirror(0), 9);
296        assert_eq!(mirror(mirror(23)), 23);
297    }
298
299    #[test]
300    fn find_stage_recognises_each_stage() {
301        for s in 0..=last() {
302            assert_eq!(find_stage(keys_string(s)), Some(s));
303        }
304        assert_eq!(find_stage("nope"), None);
305    }
306
307    fn plan_with(stages: &[&str], extra: &str) -> Result<Plan> {
308        let mut t = format!("name = \"t\"\n{extra}\n");
309        t.push_str("common_words = [");
310        for i in 0..60 {
311            t.push_str(&format!("\"w{i}\", "));
312        }
313        t.push_str("]\n");
314        for (i, k) in stages.iter().enumerate() {
315            t.push_str(&format!("[[stage]]\nname = \"s{i}\"\nkeys = \"{k}\"\n"));
316        }
317        Plan::parse(&t)
318    }
319
320    #[test]
321    fn rejects_broken_plans() {
322        let q = "qwertyuiop asdfghjkl; zxcvbnm,./";
323        let swap = "qwertyuiop asdfghjkl; zxcvbmn,./";
324        assert!(plan_with(&[q, swap], "").is_ok());
325        assert!(
326            plan_with(&[q], "")
327                .unwrap_err()
328                .to_string()
329                .contains("two stages")
330        );
331        assert!(
332            plan_with(&[q, q], "")
333                .unwrap_err()
334                .to_string()
335                .contains("doesn't move")
336        );
337        assert!(
338            plan_with(&[q, "qwertyuiop"], "")
339                .unwrap_err()
340                .to_string()
341                .contains("30")
342        );
343        assert!(
344            plan_with(&[q, "qwertyuiop asdfghjkl; zxcvbnm,.!"], "")
345                .unwrap_err()
346                .to_string()
347                .contains("rearrangement")
348        );
349        assert!(plan_with(&[q, swap], "home_row_mods = [\"hyper\"]").is_err());
350    }
351}