mathtex-editor-core 0.3.0

Headless core of the mathtex structural math editor: model, operations, navigation, selection, IR matching
Documentation
//! Pure model navigation, rendered geometry and hit testing live in the matcher.

use crate::model::{Cursor, Kind, NodeId, SeqId, Tree, Variant};

/// Backspace target for a neighboring structure, excluding leaves and `\text{}`.
pub(crate) fn adjacent_structure_backward(tree: &Tree, at: Cursor) -> Option<NodeId> {
    let prev = *tree.items(at.seq).get(at.index.checked_sub(1)?)?;
    is_selectable_structure(tree, prev).then_some(prev)
}

/// Mirror of [`adjacent_structure_backward`] for forward deletion.
pub(crate) fn adjacent_structure_forward(tree: &Tree, at: Cursor) -> Option<NodeId> {
    let next = *tree.items(at.seq).get(at.index)?;
    is_selectable_structure(tree, next).then_some(next)
}

fn is_selectable_structure(tree: &Tree, node: NodeId) -> bool {
    !tree.child_seqs(node).is_empty()
        && !matches!(tree.kind(node), Some(Kind::Styled { variant: Variant::Text, .. }))
}

/// Whether `at` is the start gap of a nonempty Script base, which is never a resting position.
pub(crate) fn is_illegal(tree: &Tree, at: Cursor) -> bool {
    at.index == 0 && !tree.is_empty(at.seq) && tree.script_base_node(at.seq).is_some()
}

/// Clamp a stale gap and move off illegal Script base starts to before the whole script.
pub(crate) fn normalize(tree: &Tree, mut at: Cursor) -> Cursor {
    at.index = at.index.min(tree.len(at.seq));
    while is_illegal(tree, at) {
        match tree.before_parent(at.seq) {
            Some(c) => at = c,
            None => break,
        }
    }
    at
}

/// Move one structural position right, `None` at the root's right boundary.
pub(crate) fn move_right(tree: &Tree, at: Cursor) -> Option<Cursor> {
    if at.index < tree.len(at.seq) {
        let node = tree.items(at.seq)[at.index];
        return match tree.child_seqs(node).first() {
            None => Some(Cursor { seq: at.seq, index: at.index + 1 }),
            // Entering a nonempty Script base skips its illegal start gap.
            Some(&first) if is_illegal(tree, Cursor { seq: first, index: 0 }) => {
                move_right(tree, Cursor { seq: first, index: 0 })
            }
            Some(&first) => Some(Cursor { seq: first, index: 0 }),
        };
    }
    let parent = tree.seq_parent(at.seq)?;
    let slots = tree.child_seqs(parent);
    let pos = slots.iter().position(|&s| s == at.seq)?;
    if let Some(&next) = slots.get(pos + 1) {
        return Some(Cursor { seq: next, index: 0 });
    }
    let (pseq, pi) = tree.index_in_parent(parent)?;
    Some(Cursor { seq: pseq, index: pi + 1 })
}

/// Mirror of [`move_right`].
pub(crate) fn move_left(tree: &Tree, at: Cursor) -> Option<Cursor> {
    let candidate = if at.index > 0 {
        let node = tree.items(at.seq)[at.index - 1];
        match tree.child_seqs(node).last() {
            None => Cursor { seq: at.seq, index: at.index - 1 },
            Some(&last) => Cursor { seq: last, index: tree.len(last) },
        }
    } else {
        let parent = tree.seq_parent(at.seq)?;
        let slots = tree.child_seqs(parent);
        let pos = slots.iter().position(|&s| s == at.seq)?;
        match pos.checked_sub(1) {
            Some(p) => Cursor { seq: slots[p], index: tree.len(slots[p]) },
            None => tree.before_parent(at.seq)?,
        }
    };
    Some(normalize(tree, candidate))
}

/// Vertical motion out of `at.seq` into a sibling slot of its own parent node.
fn vertical_step(tree: &Tree, at: Cursor, up: bool) -> Option<SeqId> {
    let parent = tree.seq_parent(at.seq)?;
    // Each arm lists its slots top to bottom.
    let stack: Vec<SeqId> = match tree.kind(parent)? {
        Kind::Frac { num, den, .. } => vec![*num, *den],
        Kind::Script { base, sub, sup } => sup.iter().copied().chain([*base]).chain(sub.iter().copied()).collect(),
        Kind::BigOp { lower, upper, .. } => vec![*upper, *lower],
        Kind::Sqrt { index, radicand } => vec![*index, *radicand],
        Kind::UnderOver { base, over, under, .. } => {
            over.iter().copied().chain([*base]).chain(under.iter().copied()).collect()
        }
        Kind::Matrix { rows, .. } => {
            let (r, c) = rows
                .iter()
                .enumerate()
                .find_map(|(ri, row)| row.iter().position(|&s| s == at.seq).map(|ci| (ri, ci)))?;
            let nr = if up { r.checked_sub(1)? } else { r + 1 };
            return rows.get(nr).and_then(|row| row.get(c)).copied();
        }
        _ => return None,
    };
    let pos = stack.iter().position(|&s| s == at.seq)?;
    let next = if up { pos.checked_sub(1)? } else { pos + 1 };
    stack.get(next).copied()
}

/// Structural vertical motion, climbing to the nearest ancestor slot that can move vertically.
pub(crate) fn vertical(tree: &Tree, at: Cursor, up: bool) -> Option<Cursor> {
    let mut from = at;
    loop {
        if let Some(target) = vertical_step(tree, from, up) {
            let index = from.index.min(tree.len(target));
            // Land at the legal Script base end instead of its illegal start.
            let index = if tree.script_base_node(target).is_some() { tree.len(target) } else { index };
            return Some(normalize(tree, Cursor { seq: target, index }));
        }
        from = tree.before_parent(from.seq)?;
    }
}

/// The next empty slot after (or before) `at` in reading order, found in one tree walk.
pub(crate) fn next_empty_slot(tree: &Tree, at: Cursor, forward: bool) -> Option<SeqId> {
    let mut armed = false;
    walk(tree, tree.root(), at, forward, &mut armed)
}

fn walk(tree: &Tree, seq: SeqId, at: Cursor, forward: bool, armed: &mut bool) -> Option<SeqId> {
    if *armed && tree.is_empty(seq) {
        return Some(seq);
    }
    let items = tree.items(seq);
    let gaps: Box<dyn Iterator<Item = usize>> =
        if forward { Box::new(0..=items.len()) } else { Box::new((0..=items.len()).rev()) };
    for gap in gaps {
        if seq == at.seq && gap == at.index {
            *armed = true;
        }
        // Moving right enters the node after the gap, moving left the node before it.
        let node = if forward { items.get(gap) } else { gap.checked_sub(1).and_then(|g| items.get(g)) };
        let Some(&node) = node else { continue };
        let mut slots = tree.child_seqs(node);
        if !forward {
            slots.reverse();
        }
        for s in slots {
            if let Some(found) = walk(tree, s, at, forward, armed) {
                return Some(found);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{FracStyle, MathClass, ScriptSlot, Symbol};

    fn atom(c: &str) -> Symbol {
        Symbol { latex: c.into(), class: MathClass::Ord }
    }

    fn put(t: &mut Tree, seq: SeqId, s: &str) {
        for ch in s.chars() {
            let at = Cursor { seq, index: t.len(seq) };
            t.insert_atom(at, None, atom(&ch.to_string())).unwrap();
        }
    }

    /// Build `x^2` and return `(root, script, base, sup)`.
    fn x_squared(t: &mut Tree) -> (SeqId, NodeId, SeqId, SeqId) {
        let root = t.root();
        put(t, root, "x");
        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup, None).unwrap();
        put(t, c.seq, "2");
        let script = t.items(root)[0];
        let slots = t.child_seqs(script);
        (root, script, slots[0], slots[1])
    }

    #[test]
    fn script_base_is_entered_at_its_end() {
        let mut t = Tree::new();
        let (root, _script, base, sup) = x_squared(&mut t);
        let r = move_right(&t, Cursor { seq: root, index: 0 }).unwrap();
        assert_eq!(r, Cursor { seq: base, index: 1 });
        assert_eq!(move_right(&t, r).unwrap(), Cursor { seq: sup, index: 0 });
        assert_eq!(move_left(&t, Cursor { seq: base, index: 1 }).unwrap(), Cursor { seq: root, index: 0 });
        assert_eq!(normalize(&t, Cursor { seq: base, index: 0 }), Cursor { seq: root, index: 0 });
    }

    #[test]
    fn structural_script_base_is_enterable() {
        let mut t = Tree::new();
        let root = t.root();
        t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None).unwrap();
        let frac = t.items(root)[0];
        t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup, None).unwrap();
        let base = t.child_seqs(t.items(root)[0])[0];
        let (num, den) = (t.child_seqs(frac)[0], t.child_seqs(frac)[1]);
        assert_eq!(move_right(&t, Cursor { seq: root, index: 0 }).unwrap(), Cursor { seq: num, index: 0 });
        assert_eq!(move_left(&t, Cursor { seq: base, index: 1 }).unwrap().seq, den);
    }

    /// Leftward `\sum_2^3` navigation visits limits only, the operator nucleus is not a stop.
    #[test]
    fn bigop_left_navigation_visits_lower_then_upper() {
        let mut t = Tree::new();
        let root = t.root();
        let op = Symbol { latex: "\\sum".into(), class: MathClass::Op };
        t.insert_big_op(Cursor { seq: root, index: 0 }, None, op).unwrap();
        let cs = t.child_seqs(t.items(root)[0]);
        let (upper, lower) = (cs[0], cs[1]);
        put(&mut t, lower, "2");
        put(&mut t, upper, "3");
        let mut c = Cursor { seq: root, index: 1 };
        for (i, (seq, index)) in [(lower, 1), (lower, 0), (upper, 1), (upper, 0), (root, 0)].into_iter().enumerate() {
            c = move_left(&t, c).unwrap_or_else(|| panic!("step {i}: no move"));
            assert_eq!(c, Cursor { seq, index }, "left step {i}");
        }
    }

    #[test]
    fn vertical_climbs_to_a_movable_ancestor() {
        let mut t = Tree::new();
        let root = t.root();
        let num = t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None).unwrap();
        let body = t.insert_delimiters(num, '(', ')', None).unwrap();
        let den = t.child_seqs(t.items(root)[0])[1];
        // A Delim body has no vertical neighbor, so Down uses the numerator's.
        assert_eq!(vertical(&t, body, false).unwrap().seq, den);
        assert_eq!(vertical(&t, body, true), None);
    }

    #[test]
    fn vertical_moves_through_sqrt_and_under_over() {
        let mut t = Tree::new();
        let root = t.root();
        let radicand = t.insert_sqrt(Cursor { seq: root, index: 0 }, None).unwrap();
        let index = t.child_seqs(t.items(root)[0])[0];
        assert_eq!(vertical(&t, radicand, true).unwrap().seq, index);
        assert_eq!(vertical(&t, Cursor { seq: index, index: 0 }, false).unwrap().seq, radicand.seq);
        let spec = crate::model::UnderOverSpec {
            over: true,
            under: true,
            over_deco: crate::model::Deco::Brace,
            under_deco: crate::model::Deco::None,
        };
        let base = t.insert_under_over(Cursor { seq: root, index: 1 }, spec, None).unwrap();
        let slots = t.child_seqs(t.items(root)[1]);
        assert_eq!(vertical(&t, base, true).unwrap().seq, slots[0]);
        assert_eq!(vertical(&t, base, false).unwrap().seq, slots[2]);
    }

    #[test]
    fn empty_slot_walk_matches_reading_order() {
        let mut t = Tree::new();
        let root = t.root();
        let a = t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None).unwrap();
        let b = t.insert_fraction(Cursor { seq: root, index: 1 }, FracStyle::Bar, None).unwrap();
        let den_a = t.child_seqs(t.items(root)[0])[1];
        assert_eq!(next_empty_slot(&t, Cursor { seq: root, index: 0 }, true), Some(a.seq));
        assert_eq!(next_empty_slot(&t, a, true), Some(den_a));
        assert_eq!(next_empty_slot(&t, Cursor { seq: den_a, index: 0 }, true), Some(b.seq));
        assert_eq!(next_empty_slot(&t, b, false), Some(den_a));
        assert_eq!(next_empty_slot(&t, Cursor { seq: root, index: 0 }, false), None);
    }
}