mathtex-editor-core 0.1.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 [`crate::matcher`].

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

/// Canonical slot order for navigation, export, and deletion, matrices are row-major.
pub fn slots_in_order(tree: &Tree, node: NodeId) -> Vec<SeqId> {
    tree.child_seqs(node)
}

/// Backspace target for a neighboring composite, excluding leaves, seq starts, and `\text{}`.
pub fn adjacent_structure_backward(tree: &Tree, at: Cursor) -> Option<NodeId> {
    if at.index == 0 {
        return None;
    }
    let prev = tree.items(at.seq)[at.index - 1];
    if tree.child_seqs(prev).is_empty() {
        return None; // leaf
    }
    if matches!(tree.kind(prev), Some(Kind::Styled { variant: Variant::Text, .. })) {
        return None; // \text{} carve-out
    }
    Some(prev)
}

/// Mirror of [`adjacent_structure_backward`] for forward deletion.
pub fn adjacent_structure_forward(tree: &Tree, at: Cursor) -> Option<NodeId> {
    if at.index >= tree.len(at.seq) {
        return None;
    }
    let next = tree.items(at.seq)[at.index];
    if tree.child_seqs(next).is_empty() {
        return None;
    }
    if matches!(tree.kind(next), Some(Kind::Styled { variant: Variant::Text, .. })) {
        return None;
    }
    Some(next)
}

/// Normalize the illegal start of a non-empty Script base to before the whole script.
pub fn normalize(tree: &Tree, at: Cursor) -> Cursor {
    if at.index == 0 && !tree.is_empty(at.seq) {
        if let Some(script) = tree.script_base_node(at.seq) {
            if let Some((pseq, pi)) = tree.index_in_parent(script) {
                return Cursor { seq: pseq, index: pi };
            }
        }
    }
    at
}

/// Move one structural position right, returning `None` at the root's right boundary.
pub fn move_right(tree: &Tree, at: Cursor) -> Option<Cursor> {
    if at.index < tree.len(at.seq) {
        let node = tree.items(at.seq)[at.index];
        match tree.child_seqs(node).first() {
            None => Some(Cursor {
                seq: at.seq,
                index: at.index + 1,
            }), // leaf: step over
            Some(&first) => {
                // Skip the illegal Script base start while still entering a structural base node.
                if tree.script_base_node(first).is_some() && !tree.is_empty(first) {
                    move_right(tree, Cursor { seq: first, index: 0 })
                } else {
                    Some(Cursor { seq: first, index: 0 })
                }
            } // composite: descend into first slot
        }
    } else {
        // end of seq → next sibling slot, else ascend past the parent node
        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) {
            Some(Cursor { seq: next, index: 0 })
        } else {
            let (pseq, pi) = tree.index_in_parent(parent)?;
            Some(Cursor {
                seq: pseq,
                index: pi + 1,
            })
        }
    }
}

/// Mirror of [`move_right`].
pub 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)?;
        if pos > 0 {
            let prev = slots[pos - 1];
            Cursor {
                seq: prev,
                index: tree.len(prev),
            }
        } else {
            let (pseq, pi) = tree.index_in_parent(parent)?;
            Cursor {
                seq: pseq,
                index: pi,
            }
        }
    };
    // Stepping left off a Script base normalizes to before the whole script.
    Some(normalize(tree, candidate))
}

/// Descend into the structure to the right of the cursor (its first slot).
pub fn enter(tree: &Tree, at: Cursor) -> Option<Cursor> {
    if at.index < tree.len(at.seq) {
        let node = tree.items(at.seq)[at.index];
        tree.child_seqs(node)
            .first()
            .map(|&s| Cursor { seq: s, index: 0 })
    } else {
        None
    }
}

/// Ascend to the position just before the parent node in the grandparent sequence.
pub fn exit(tree: &Tree, at: Cursor) -> Option<Cursor> {
    let parent = tree.seq_parent(at.seq)?;
    let (pseq, pi) = tree.index_in_parent(parent)?;
    Some(Cursor {
        seq: pseq,
        index: pi,
    })
}

/// Structural vertical motion across Frac, Script, BigOp, and Matrix slots.
pub fn vertical(tree: &Tree, at: Cursor, up: bool) -> Option<Cursor> {
    let parent = tree.seq_parent(at.seq)?;
    let target = match tree.kind(parent)? {
        Kind::Frac { num, den, .. } => {
            if at.seq == *num && !up {
                Some(*den)
            } else if at.seq == *den && up {
                Some(*num)
            } else {
                None
            }
        }
        Kind::Script { base, sub, sup } => {
            // top→bottom stack: sup, base, sub
            let mut order = Vec::new();
            order.extend(sup.iter().copied());
            order.push(*base);
            order.extend(sub.iter().copied());
            let pos = order.iter().position(|&s| s == at.seq)?;
            let next = if up { pos.checked_sub(1)? } else { pos + 1 };
            order.get(next).copied()
        }
        Kind::BigOp { lower, upper, .. } => {
            // upper above the operator, lower below it.
            if at.seq == *lower && up {
                Some(*upper)
            } else if at.seq == *upper && !up {
                Some(*lower)
            } else {
                None
            }
        }
        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 };
            rows.get(nr).and_then(|row| row.get(c)).copied()
        }
        _ => None,
    }?;
    // Land at the legal Script base end instead of its illegal start.
    let index = if tree.script_base_node(target).is_some() && !tree.is_empty(target) {
        tree.len(target)
    } else {
        at.index.min(tree.len(target))
    };
    Some(Cursor { seq: target, index })
}

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

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

    /// Build `x^2` and return `(root, script, base, sup)`.
    fn x_squared(t: &mut Tree) -> (SeqId, NodeId, SeqId, SeqId) {
        let root = t.root();
        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
        t.insert_atom(c, atom("2"));
        let script = t.items(root)[0];
        let (base, sup) = match t.kind(script) {
            Some(Kind::Script { base, sup, .. }) => (*base, sup.unwrap()),
            _ => panic!("expected script"),
        };
        (root, script, base, sup)
    }

    /// A Script base is entered at its end, and `{|x}^2` normalizes to `|x^2`.
    #[test]
    fn script_base_single_node_navigation() {
        let mut t = Tree::new();
        let (root, _script, base, sup) = x_squared(&mut t);

        // before the script → move_right descends into the base at its END.
        let r = move_right(&t, Cursor { seq: root, index: 0 }).unwrap();
        assert_eq!((r.seq, r.index), (base, 1)); // {x|}^2
        // base end → enter the sup start.
        let r = move_right(&t, r).unwrap();
        assert_eq!((r.seq, r.index), (sup, 0)); // x^{|2}

        // moving left off the lone base node collapses to before the script.
        let l = move_left(&t, Cursor { seq: base, index: 1 }).unwrap();
        assert_eq!((l.seq, l.index), (root, 0)); // |x^2
        // and the raw illegal position normalizes the same way.
        let n = normalize(&t, Cursor { seq: base, index: 0 });
        assert_eq!((n.seq, n.index), (root, 0));
    }

    /// A structural Script base is entered after skipping its phantom start gap.
    #[test]
    fn script_base_composite_node_is_enterable() {
        let mut t = Tree::new();
        let root = t.root();
        t.insert_fraction(Cursor { seq: root, index: 0 }, crate::model::FracStyle::Bar, None);
        let frac = t.items(root)[0];
        t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup); // wraps frac as base
        let script = t.items(root)[0];
        let base = match t.kind(script) {
            Some(Kind::Script { base, .. }) => *base,
            _ => panic!(),
        };
        let (num, den) = (t.child_seqs(frac)[0], t.child_seqs(frac)[1]);

        // before the script → move_right enters the fraction's numerator.
        let r = move_right(&t, Cursor { seq: root, index: 0 }).unwrap();
        assert_eq!((r.seq, r.index), (num, 0));
        // base end → move_left enters the fraction (denominator end), not out.
        let l = move_left(&t, Cursor { seq: base, index: 1 }).unwrap();
        assert_eq!(l.seq, den);
    }

    /// Leftward `\sum_2^3` navigation visits limits only, the operator nucleus is not a stop.
    #[test]
    fn bigop_left_navigation_spec() {
        let mut t = Tree::new();
        let root = t.root();
        t.insert_big_op(Cursor { seq: root, index: 0 }, op("\\sum"));
        let bigop = t.items(root)[0];
        let cs = t.child_seqs(bigop); // [upper, lower]
        let (upper, lower) = (cs[0], cs[1]);
        t.insert_atom(Cursor { seq: lower, index: 0 }, atom("2"));
        t.insert_atom(Cursor { seq: upper, index: 0 }, atom("3"));

        let mut c = Cursor { seq: root, index: 1 }; // after the whole \sum_2^3
        let expected = [
            (lower, 1), // \sum_{2|}^3
            (lower, 0), // \sum_{|2}^3
            (upper, 1), // \sum_2^{3|}
            (upper, 0), // \sum_2^{|3}
            (root, 0),  // |\sum_2^3
        ];
        for (i, (eseq, eidx)) in expected.into_iter().enumerate() {
            c = move_left(&t, c).unwrap_or_else(|| panic!("step {i}: no move"));
            assert_eq!((c.seq, c.index), (eseq, eidx), "left step {i}");
        }
    }
}