use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
pub const EXAMPLE: &str = include_str!("../examples/very-normal.toml");
pub const OUTER_LEFT: [&str; 3] = ["tab", "esc", "shf"];
pub const OUTER_RIGHT: [&str; 3] = ["bsp", "'", "ent"];
#[derive(Debug, Clone, Deserialize)]
pub struct Plan {
pub name: String,
#[serde(default)]
pub oryx_layout: Option<String>,
#[serde(default)]
pub home_row_mods: Vec<String>,
#[serde(default)]
pub common_words: Vec<String>,
#[serde(rename = "stage")]
pub stages: Vec<Stage>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Stage {
pub name: String,
#[serde(default)]
pub blurb: String,
pub keys: String,
#[serde(default)]
pub home: f64,
#[serde(default)]
pub same_finger: f64,
#[serde(default)]
pub vim: Vec<String>,
#[serde(default)]
pub words: Vec<String>,
}
impl Plan {
pub fn parse(text: &str) -> Result<Plan> {
let mut plan: Plan = toml::from_str(text)?;
for stage in &mut plan.stages {
stage.keys.retain(|c| !c.is_whitespace());
}
plan.validate()?;
Ok(plan)
}
pub fn load(path: &Path) -> Result<Plan> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Plan::parse(&text).with_context(|| format!("in {}", path.display()))
}
fn validate(&self) -> Result<()> {
if self.stages.len() < 2 {
bail!("a plan needs at least two stages: where you start and where you're going");
}
let mut start: Vec<char> = self.stages[0].keys.chars().collect();
start.sort_unstable();
for (i, stage) in self.stages.iter().enumerate() {
let mut k: Vec<char> = stage.keys.chars().collect();
if k.len() != 30 {
bail!(
"stage {i} has {} keys; every stage has 30 (three rows of ten)",
k.len()
);
}
k.sort_unstable();
if k != start {
bail!("stage {i} isn't a rearrangement of stage 0's keys");
}
if i > 0 && stage.keys == self.stages[i - 1].keys {
bail!("stage {i} doesn't move any keys");
}
}
if !self.home_row_mods.is_empty() && self.home_row_mods.len() != 10 {
bail!(
"home_row_mods has {} entries; it needs 10, one per home row key",
self.home_row_mods.len()
);
}
if self.common_words.len() < 50 {
bail!(
"common_words needs at least 50 words for the drills; it has {}",
self.common_words.len()
);
}
for m in &self.home_row_mods {
if !matches!(m.as_str(), "" | "ctrl" | "alt" | "cmd" | "shift") {
bail!("home_row_mods can hold ctrl, alt, cmd, shift or \"\", not {m:?}");
}
}
Ok(())
}
}
pub fn plan_path() -> PathBuf {
if let Some(p) = std::env::var_os("VERY_NORMAL_PLAN") {
return PathBuf::from(p);
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default();
home.join(".config/very-normal/plan.toml")
}
static PLAN: OnceLock<Plan> = OnceLock::new();
pub fn install() -> Result<Option<PathBuf>> {
let path = plan_path();
let (plan, used) = if path.exists() {
(Plan::load(&path)?, Some(path))
} else {
(example(), None)
};
let _ = PLAN.set(plan);
Ok(used)
}
fn example() -> Plan {
Plan::parse(EXAMPLE).expect("the example plan is valid")
}
pub fn plan() -> &'static Plan {
PLAN.get_or_init(example)
}
pub fn last() -> usize {
plan().stages.len() - 1
}
fn stage(s: usize) -> &'static Stage {
&plan().stages[s.min(last())]
}
pub fn name(s: usize) -> &'static str {
&stage(s).name
}
pub fn blurb(s: usize) -> &'static str {
&stage(s).blurb
}
pub fn stats(s: usize) -> (f64, f64) {
(stage(s).home, stage(s).same_finger)
}
pub fn vim(s: usize) -> &'static [String] {
&stage(s).vim
}
pub fn words(s: usize) -> &'static [String] {
&stage(s).words
}
pub fn common_words() -> &'static [String] {
&plan().common_words
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Change {
pub index: usize,
pub from: char,
pub to: char,
}
impl Change {
pub fn right_hand(&self) -> bool {
self.index % 10 >= 5
}
}
pub fn keys(s: usize) -> Vec<char> {
stage(s).keys.chars().collect()
}
pub fn keys_string(s: usize) -> &'static str {
&stage(s).keys
}
pub fn changes(s: usize) -> Vec<Change> {
if s == 0 || s > last() {
return Vec::new();
}
let before = keys(s - 1);
keys(s)
.into_iter()
.enumerate()
.filter(|(i, c)| before[*i] != *c)
.map(|(index, to)| Change {
index,
from: before[index],
to,
})
.collect()
}
pub fn focus(s: usize) -> Vec<char> {
let mut out = Vec::new();
for c in changes(s) {
if !out.contains(&c.to) {
out.push(c.to);
}
}
out
}
pub fn moved(s: usize, index: usize) -> bool {
keys(s)[index] != keys(0)[index]
}
pub fn changed_now(s: usize, index: usize) -> bool {
changes(s).iter().any(|c| c.index == index)
}
pub fn modifier(index: usize) -> Option<&'static str> {
let m = plan().home_row_mods.get(index.checked_sub(10)?)?;
(!m.is_empty()).then_some(m.as_str())
}
pub fn position(s: usize, ch: char) -> Option<usize> {
keys(s).iter().position(|c| *c == ch)
}
pub fn mirror(index: usize) -> usize {
let (row, col) = (index / 10, index % 10);
row * 10 + (9 - col)
}
pub fn find_stage(main: &str) -> Option<usize> {
plan().stages.iter().position(|s| s.keys == main)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_example_is_marks_very_normal() {
let p = plan();
assert_eq!(p.name, "Very Normal");
assert_eq!(last(), 9);
assert_eq!(keys_string(0), "qwertyuiopasdfghjkl;zxcvbnm,./");
assert_eq!(keys_string(last()), "qwydgbhaujilstmpnoerzxcvk;f,./");
assert_eq!(modifier(13), Some("cmd"));
assert_eq!(modifier(10), None);
assert_eq!(modifier(3), None);
}
#[test]
fn every_stage_moves_a_few_keys_and_keeps_the_shortcuts() {
for s in 1..=last() {
let n = changes(s).len();
assert!((3..=5).contains(&n), "stage {s} moves {n}");
for c in "qwzxcv".chars() {
assert_eq!(position(s, c), position(0, c), "{c} moved in stage {s}");
}
}
}
#[test]
fn stage_one_swaps_e_l_t_f() {
let got: Vec<(char, char)> = changes(1).iter().map(|c| (c.from, c.to)).collect();
assert_eq!(got, vec![('e', 'l'), ('t', 'f'), ('f', 't'), ('l', 'e')]);
assert_eq!(focus(1), vec!['l', 'f', 't', 'e']);
}
#[test]
fn mirror_reflects_across_the_hands() {
assert_eq!(mirror(18), 11);
assert_eq!(mirror(0), 9);
assert_eq!(mirror(mirror(23)), 23);
}
#[test]
fn find_stage_recognises_each_stage() {
for s in 0..=last() {
assert_eq!(find_stage(keys_string(s)), Some(s));
}
assert_eq!(find_stage("nope"), None);
}
fn plan_with(stages: &[&str], extra: &str) -> Result<Plan> {
let mut t = format!("name = \"t\"\n{extra}\n");
t.push_str("common_words = [");
for i in 0..60 {
t.push_str(&format!("\"w{i}\", "));
}
t.push_str("]\n");
for (i, k) in stages.iter().enumerate() {
t.push_str(&format!("[[stage]]\nname = \"s{i}\"\nkeys = \"{k}\"\n"));
}
Plan::parse(&t)
}
#[test]
fn rejects_broken_plans() {
let q = "qwertyuiop asdfghjkl; zxcvbnm,./";
let swap = "qwertyuiop asdfghjkl; zxcvbmn,./";
assert!(plan_with(&[q, swap], "").is_ok());
assert!(
plan_with(&[q], "")
.unwrap_err()
.to_string()
.contains("two stages")
);
assert!(
plan_with(&[q, q], "")
.unwrap_err()
.to_string()
.contains("doesn't move")
);
assert!(
plan_with(&[q, "qwertyuiop"], "")
.unwrap_err()
.to_string()
.contains("30")
);
assert!(
plan_with(&[q, "qwertyuiop asdfghjkl; zxcvbnm,.!"], "")
.unwrap_err()
.to_string()
.contains("rearrangement")
);
assert!(plan_with(&[q, swap], "home_row_mods = [\"hyper\"]").is_err());
}
}