Skip to main content

mathtex_editor_core/
nav.rs

1//! Pure model navigation, rendered geometry and hit testing live in [`crate::matcher`].
2
3use crate::model::{Cursor, Kind, NodeId, SeqId, Tree, Variant};
4
5/// Canonical slot order for navigation, export, and deletion, matrices are row-major.
6pub fn slots_in_order(tree: &Tree, node: NodeId) -> Vec<SeqId> {
7    tree.child_seqs(node)
8}
9
10/// Backspace target for a neighboring composite, excluding leaves, seq starts, and `\text{}`.
11pub fn adjacent_structure_backward(tree: &Tree, at: Cursor) -> Option<NodeId> {
12    if at.index == 0 {
13        return None;
14    }
15    let prev = tree.items(at.seq)[at.index - 1];
16    if tree.child_seqs(prev).is_empty() {
17        return None; // leaf
18    }
19    if matches!(tree.kind(prev), Some(Kind::Styled { variant: Variant::Text, .. })) {
20        return None; // \text{} carve-out
21    }
22    Some(prev)
23}
24
25/// Mirror of [`adjacent_structure_backward`] for forward deletion.
26pub fn adjacent_structure_forward(tree: &Tree, at: Cursor) -> Option<NodeId> {
27    if at.index >= tree.len(at.seq) {
28        return None;
29    }
30    let next = tree.items(at.seq)[at.index];
31    if tree.child_seqs(next).is_empty() {
32        return None;
33    }
34    if matches!(tree.kind(next), Some(Kind::Styled { variant: Variant::Text, .. })) {
35        return None;
36    }
37    Some(next)
38}
39
40/// Normalize the illegal start of a non-empty Script base to before the whole script.
41pub fn normalize(tree: &Tree, at: Cursor) -> Cursor {
42    if at.index == 0 && !tree.is_empty(at.seq) {
43        if let Some(script) = tree.script_base_node(at.seq) {
44            if let Some((pseq, pi)) = tree.index_in_parent(script) {
45                return Cursor { seq: pseq, index: pi };
46            }
47        }
48    }
49    at
50}
51
52/// Move one structural position right, returning `None` at the root's right boundary.
53pub fn move_right(tree: &Tree, at: Cursor) -> Option<Cursor> {
54    if at.index < tree.len(at.seq) {
55        let node = tree.items(at.seq)[at.index];
56        match tree.child_seqs(node).first() {
57            None => Some(Cursor {
58                seq: at.seq,
59                index: at.index + 1,
60            }), // leaf: step over
61            Some(&first) => {
62                // Skip the illegal Script base start while still entering a structural base node.
63                if tree.script_base_node(first).is_some() && !tree.is_empty(first) {
64                    move_right(tree, Cursor { seq: first, index: 0 })
65                } else {
66                    Some(Cursor { seq: first, index: 0 })
67                }
68            } // composite: descend into first slot
69        }
70    } else {
71        // end of seq → next sibling slot, else ascend past the parent node
72        let parent = tree.seq_parent(at.seq)?;
73        let slots = tree.child_seqs(parent);
74        let pos = slots.iter().position(|&s| s == at.seq)?;
75        if let Some(&next) = slots.get(pos + 1) {
76            Some(Cursor { seq: next, index: 0 })
77        } else {
78            let (pseq, pi) = tree.index_in_parent(parent)?;
79            Some(Cursor {
80                seq: pseq,
81                index: pi + 1,
82            })
83        }
84    }
85}
86
87/// Mirror of [`move_right`].
88pub fn move_left(tree: &Tree, at: Cursor) -> Option<Cursor> {
89    let candidate = if at.index > 0 {
90        let node = tree.items(at.seq)[at.index - 1];
91        match tree.child_seqs(node).last() {
92            None => Cursor {
93                seq: at.seq,
94                index: at.index - 1,
95            },
96            Some(&last) => Cursor {
97                seq: last,
98                index: tree.len(last),
99            },
100        }
101    } else {
102        let parent = tree.seq_parent(at.seq)?;
103        let slots = tree.child_seqs(parent);
104        let pos = slots.iter().position(|&s| s == at.seq)?;
105        if pos > 0 {
106            let prev = slots[pos - 1];
107            Cursor {
108                seq: prev,
109                index: tree.len(prev),
110            }
111        } else {
112            let (pseq, pi) = tree.index_in_parent(parent)?;
113            Cursor {
114                seq: pseq,
115                index: pi,
116            }
117        }
118    };
119    // Stepping left off a Script base normalizes to before the whole script.
120    Some(normalize(tree, candidate))
121}
122
123/// Descend into the structure to the right of the cursor (its first slot).
124pub fn enter(tree: &Tree, at: Cursor) -> Option<Cursor> {
125    if at.index < tree.len(at.seq) {
126        let node = tree.items(at.seq)[at.index];
127        tree.child_seqs(node)
128            .first()
129            .map(|&s| Cursor { seq: s, index: 0 })
130    } else {
131        None
132    }
133}
134
135/// Ascend to the position just before the parent node in the grandparent sequence.
136pub fn exit(tree: &Tree, at: Cursor) -> Option<Cursor> {
137    let parent = tree.seq_parent(at.seq)?;
138    let (pseq, pi) = tree.index_in_parent(parent)?;
139    Some(Cursor {
140        seq: pseq,
141        index: pi,
142    })
143}
144
145/// Structural vertical motion across Frac, Script, BigOp, and Matrix slots.
146pub fn vertical(tree: &Tree, at: Cursor, up: bool) -> Option<Cursor> {
147    let parent = tree.seq_parent(at.seq)?;
148    let target = match tree.kind(parent)? {
149        Kind::Frac { num, den, .. } => {
150            if at.seq == *num && !up {
151                Some(*den)
152            } else if at.seq == *den && up {
153                Some(*num)
154            } else {
155                None
156            }
157        }
158        Kind::Script { base, sub, sup } => {
159            // top→bottom stack: sup, base, sub
160            let mut order = Vec::new();
161            order.extend(sup.iter().copied());
162            order.push(*base);
163            order.extend(sub.iter().copied());
164            let pos = order.iter().position(|&s| s == at.seq)?;
165            let next = if up { pos.checked_sub(1)? } else { pos + 1 };
166            order.get(next).copied()
167        }
168        Kind::BigOp { lower, upper, .. } => {
169            // upper above the operator, lower below it.
170            if at.seq == *lower && up {
171                Some(*upper)
172            } else if at.seq == *upper && !up {
173                Some(*lower)
174            } else {
175                None
176            }
177        }
178        Kind::Matrix { rows, .. } => {
179            let (r, c) = rows.iter().enumerate().find_map(|(ri, row)| {
180                row.iter().position(|&s| s == at.seq).map(|ci| (ri, ci))
181            })?;
182            let nr = if up { r.checked_sub(1)? } else { r + 1 };
183            rows.get(nr).and_then(|row| row.get(c)).copied()
184        }
185        _ => None,
186    }?;
187    // Land at the legal Script base end instead of its illegal start.
188    let index = if tree.script_base_node(target).is_some() && !tree.is_empty(target) {
189        tree.len(target)
190    } else {
191        at.index.min(tree.len(target))
192    };
193    Some(Cursor { seq: target, index })
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::model::{Cursor, MathClass, ScriptSlot, Symbol};
200
201    fn atom(c: &str) -> Symbol {
202        Symbol { latex: c.into(), class: MathClass::Ord }
203    }
204    fn op(c: &str) -> Symbol {
205        Symbol { latex: c.into(), class: MathClass::Op }
206    }
207
208    /// Build `x^2` and return `(root, script, base, sup)`.
209    fn x_squared(t: &mut Tree) -> (SeqId, NodeId, SeqId, SeqId) {
210        let root = t.root();
211        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
212        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
213        t.insert_atom(c, atom("2"));
214        let script = t.items(root)[0];
215        let (base, sup) = match t.kind(script) {
216            Some(Kind::Script { base, sup, .. }) => (*base, sup.unwrap()),
217            _ => panic!("expected script"),
218        };
219        (root, script, base, sup)
220    }
221
222    /// A Script base is entered at its end, and `{|x}^2` normalizes to `|x^2`.
223    #[test]
224    fn script_base_single_node_navigation() {
225        let mut t = Tree::new();
226        let (root, _script, base, sup) = x_squared(&mut t);
227
228        // before the script → move_right descends into the base at its END.
229        let r = move_right(&t, Cursor { seq: root, index: 0 }).unwrap();
230        assert_eq!((r.seq, r.index), (base, 1)); // {x|}^2
231        // base end → enter the sup start.
232        let r = move_right(&t, r).unwrap();
233        assert_eq!((r.seq, r.index), (sup, 0)); // x^{|2}
234
235        // moving left off the lone base node collapses to before the script.
236        let l = move_left(&t, Cursor { seq: base, index: 1 }).unwrap();
237        assert_eq!((l.seq, l.index), (root, 0)); // |x^2
238        // and the raw illegal position normalizes the same way.
239        let n = normalize(&t, Cursor { seq: base, index: 0 });
240        assert_eq!((n.seq, n.index), (root, 0));
241    }
242
243    /// A structural Script base is entered after skipping its phantom start gap.
244    #[test]
245    fn script_base_composite_node_is_enterable() {
246        let mut t = Tree::new();
247        let root = t.root();
248        t.insert_fraction(Cursor { seq: root, index: 0 }, crate::model::FracStyle::Bar, None);
249        let frac = t.items(root)[0];
250        t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup); // wraps frac as base
251        let script = t.items(root)[0];
252        let base = match t.kind(script) {
253            Some(Kind::Script { base, .. }) => *base,
254            _ => panic!(),
255        };
256        let (num, den) = (t.child_seqs(frac)[0], t.child_seqs(frac)[1]);
257
258        // before the script → move_right enters the fraction's numerator.
259        let r = move_right(&t, Cursor { seq: root, index: 0 }).unwrap();
260        assert_eq!((r.seq, r.index), (num, 0));
261        // base end → move_left enters the fraction (denominator end), not out.
262        let l = move_left(&t, Cursor { seq: base, index: 1 }).unwrap();
263        assert_eq!(l.seq, den);
264    }
265
266    /// Leftward `\sum_2^3` navigation visits limits only, the operator nucleus is not a stop.
267    #[test]
268    fn bigop_left_navigation_spec() {
269        let mut t = Tree::new();
270        let root = t.root();
271        t.insert_big_op(Cursor { seq: root, index: 0 }, op("\\sum"));
272        let bigop = t.items(root)[0];
273        let cs = t.child_seqs(bigop); // [upper, lower]
274        let (upper, lower) = (cs[0], cs[1]);
275        t.insert_atom(Cursor { seq: lower, index: 0 }, atom("2"));
276        t.insert_atom(Cursor { seq: upper, index: 0 }, atom("3"));
277
278        let mut c = Cursor { seq: root, index: 1 }; // after the whole \sum_2^3
279        let expected = [
280            (lower, 1), // \sum_{2|}^3
281            (lower, 0), // \sum_{|2}^3
282            (upper, 1), // \sum_2^{3|}
283            (upper, 0), // \sum_2^{|3}
284            (root, 0),  // |\sum_2^3
285        ];
286        for (i, (eseq, eidx)) in expected.into_iter().enumerate() {
287            c = move_left(&t, c).unwrap_or_else(|| panic!("step {i}: no move"));
288            assert_eq!((c.seq, c.index), (eseq, eidx), "left step {i}");
289        }
290    }
291}