1use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10
11use anyhow::{Context, Result, bail};
12use serde::Deserialize;
13
14pub 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 #[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 #[serde(default)]
42 pub home: f64,
43 #[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
111pub 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
124pub 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
161pub 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
199pub 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
217pub 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
228pub 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
237pub 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
247pub fn mirror(index: usize) -> usize {
249 let (row, col) = (index / 10, index % 10);
250 row * 10 + (9 - col)
251}
252
253pub 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}