mathtex-editor-keymap 0.3.0

Pluggable default keymap for mathtex-editor, translating input into core commands
Documentation
//! Seeded interleavings of typing and host commands, checking the keymap only deletes what the run typed.

use std::collections::HashMap;

use mathtex_editor_core::{CaretPath, Editor, MathClass, NodeDoc, Side, Snapshot};

use crate::*;

/// xorshift64star, enough to spread cases without a dependency.
struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        self.0 ^= self.0 >> 12;
        self.0 ^= self.0 << 25;
        self.0 ^= self.0 >> 27;
        self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }

    fn below(&mut self, n: usize) -> usize {
        (self.next() % n as u64) as usize
    }

    fn pick<T: Copy>(&mut self, items: &[T]) -> T {
        items[self.below(items.len())]
    }

    fn chance(&mut self, percent: usize) -> bool {
        self.below(100) < percent
    }
}

const WORDS: &[&str] = &["pi", "sin", "in", "eta", "beta", "to", "text", "sum", "frac", "xpi", "pin"];
const CHARS: &[char] = &['p', 'i', 's', 'n', 'x', 't', 'o', 'e', '<', '=', '>', '-', '~', '|', '(', ')', '\'', '^', '/'];
const HOST_ATOMS: &[&str] = &["p", "i", "n", "\\alpha"];

// Atoms that existed before the current run carry this class, nothing the keymap types has it.
const OLD: MathClass = MathClass::Inner;

/// Counts of atoms from before the current run, by LaTeX.
fn old_atoms(ed: &Editor) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    ed.document().visit(|n| {
        if let NodeDoc::Atom(s) = n {
            if s.class == OLD {
                *counts.entry(s.latex.clone()).or_insert(0) += 1;
            }
        }
    });
    counts
}

struct Run {
    ed: Editor,
    km: Keymap,
    history: Vec<Snapshot>,
    conversions: usize,
}

impl Run {
    fn apply(&mut self, cmds: Vec<Command>, seed: u64) {
        for cmd in cmds {
            let selected = self.ed.selection().is_some();
            let before = old_atoms(&self.ed);
            let out = self.ed.exec(cmd.clone());
            assert!(!out.close, "seed {seed}: {cmd:?} asked to close");
            let after = old_atoms(&self.ed);
            // Typing over a selection replaces it by design.
            if !selected {
                for (latex, &n) in &before {
                    let left = after.get(latex).copied().unwrap_or(0);
                    assert!(left >= n, "seed {seed}: {cmd:?} deleted an earlier {latex}");
                }
            }
            if matches!(cmd, Command::ReplaceTyped { .. }) && out.changed {
                self.conversions += 1;
            }
        }
    }

    fn user(&mut self, rng: &mut Rng, seed: u64) {
        let text = if rng.chance(35) {
            format!("{} ", rng.pick(WORDS))
        } else if rng.chance(15) {
            " ".to_string()
        } else {
            rng.pick(CHARS).to_string()
        };
        if rng.chance(30) {
            let ctx = self.ed.input_context();
            let cmds = self.km.map_text(&text, &ctx);
            self.apply(cmds, seed);
            return;
        }
        for c in text.chars() {
            let ctx = self.ed.input_context();
            let key = KeyInput { key: c.to_string(), shift: false, ctrl: false, alt: false, meta: false };
            let cmds = self.km.map_key(&key, &ctx);
            self.apply(cmds, seed);
        }
    }

    fn host(&mut self, rng: &mut Rng, resets: bool) {
        self.history.push(self.ed.snapshot());
        match rng.below(9) {
            0 => {
                let dir = rng.pick(&[Dir::Left, Dir::Right, Dir::Up, Dir::Down]);
                let _ = self.ed.exec(Command::Move(dir));
            }
            1 => {
                let len = self.ed.document().len();
                self.ed.set_cursor(&CaretPath::root(rng.below(len + 1))).unwrap();
            }
            2 => {
                let back = self.history[rng.below(self.history.len())].clone();
                self.ed.restore(&back).unwrap();
            }
            3 => {
                let latex = rng.pick(HOST_ATOMS);
                let _ = self.ed.exec(Command::InsertAtom(Symbol { latex: latex.into(), class: OLD }));
            }
            4 => {
                let _ = self.ed.exec(Command::InsertMatrix { env: MatrixEnv::Pmatrix, rows: 1, cols: 2 });
            }
            5 => {
                let _ = self.ed.exec(Command::MatrixInsertRow(rng.pick(&[Side::Before, Side::After])));
            }
            6 => {
                let _ = self.ed.exec(Command::DeleteBackward);
            }
            7 => {
                let _ = self.ed.exec(Command::Extend(Dir::Left));
            }
            _ => {
                let _ = self.ed.exec(Command::Collapse);
            }
        }
        self.age();
        if resets {
            self.km.reset();
        }
    }

    /// Mark every atom as older than the next run, skipped while a menu is open since restoring closes it.
    fn age(&mut self) {
        if self.ed.menu().is_some() {
            return;
        }
        let mut snap = self.ed.snapshot();
        snap.document.visit_mut(|n| {
            if let NodeDoc::Atom(s) = n {
                s.class = OLD;
            }
        });
        self.ed.restore(&snap).unwrap();
    }
}

#[test]
fn keymap_commands_never_delete_atoms_typed_outside_the_current_run() {
    let mut conversions = 0;
    for seed in 1..=300u64 {
        let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15));
        let mut run = Run { ed: Editor::new(), km: Keymap::new(), history: Vec::new(), conversions: 0 };
        // Odd seeds play a host that forgets `reset`, leaving the context serial as the only guard.
        let resets = seed % 2 == 0;
        for _ in 0..80 {
            if rng.chance(35) {
                run.host(&mut rng, resets);
            } else {
                run.user(&mut rng, seed);
            }
        }
        conversions += run.conversions;
    }
    assert!(conversions > 500, "only {conversions} conversions ran");
}