Skip to main content

mathtex_editor_core/
matcher.rs

1//! Matcher geometry and render support for mapping model spans to mathtex IR boxes in SVG point units.
2
3use std::collections::HashMap;
4
5use crate::command::Point;
6use crate::export::SpanMap;
7use crate::host::{HostObject, Metrics, Rect, RenderOutput};
8use crate::menu::{Menu, MenuItem, MenuView};
9use crate::model::{Cursor, Kind, NodeId, Selection, SeqId, Tree};
10
11use mathtex_ir::{Fragment, LayoutNode, LayoutNodeKind, NodeId as IrId, Point as IrPoint};
12
13/// Geometry for each model element, resolved from the IR in points.
14#[derive(Debug, Clone, Default)]
15pub struct BoxMap {
16    /// Rectangles for model nodes.
17    pub node: HashMap<NodeId, Rect>,
18    /// Rectangles for model sequences.
19    pub seq: HashMap<SeqId, Rect>,
20}
21
22/// Match the typeset IR to the model by source span, then compute caret and selection rects.
23pub(crate) fn render(
24    tree: &Tree,
25    cursor: Cursor,
26    sel: Option<Selection>,
27    spans: &SpanMap,
28    ir: Fragment,
29    menu: Option<&Menu>,
30) -> RenderOutput {
31    let boxes = match_boxes(spans, &ir);
32    let caret = caret_rect(&boxes, tree, cursor);
33    let selection = sel
34        .map(|s| selection_rects(&boxes, tree, s))
35        .unwrap_or_default();
36    // Empty slot phantom boxes become placeholder rects for the host to draw.
37    let placeholders: Vec<Rect> = spans
38        .seq
39        .keys()
40        .filter(|&&s| tree.is_empty(s))
41        .filter_map(|s| boxes.seq.get(s).copied())
42        .collect();
43    let metrics = Metrics {
44        width: pt(ir.surface.width),
45        height: pt(ir.surface.height),
46        baseline: pt(ir.surface.baseline),
47    };
48    // The dropdown anchors at the swappable or deletable node's own box.
49    let menu = menu.map(|m| MenuView {
50        anchor: boxes.node.get(&m.anchor).copied().unwrap_or(ZERO),
51        items: m
52            .visible()
53            .iter()
54            .map(|row| MenuItem { label: row.label() })
55            .collect(),
56        selected: m.selected,
57        query: m.query.clone(),
58    });
59    // Host object atoms report their matched box so the host can overlay their content.
60    let host_objects: Vec<HostObject> = spans
61        .node
62        .keys()
63        .filter_map(|&n| match tree.kind(n) {
64            Some(Kind::HostBox { token }) => boxes
65                .node
66                .get(&n)
67                .map(|&rect| HostObject { token: *token, rect }),
68            _ => None,
69        })
70        .collect();
71    RenderOutput {
72        ir,
73        caret,
74        selection,
75        placeholders,
76        metrics,
77        menu,
78        host_objects,
79    }
80}
81
82/// Map each model element to the union of IR boxes whose source span is contained in its export range.
83pub fn match_boxes(spans: &SpanMap, fragment: &Fragment) -> BoxMap {
84    let abs = absolute_origins(fragment);
85    // Parent links let empty range fallback walk through redundant wrappers.
86    let mut parent: HashMap<IrId, IrId> = HashMap::new();
87    for n in &fragment.nodes {
88        for c in children_of(n) {
89            parent.insert(c, n.id);
90        }
91    }
92    // Index leaves, fallback containers, and box metrics by source span.
93    let mut box_metrics: Vec<(usize, usize, f64, f64)> = Vec::new();
94    for n in &fragment.nodes {
95        let LayoutNodeKind::Box(bx) = &n.kind else { continue };
96        let Some(s) = n.primary_source else { continue };
97        let (a, b) = (s.span.start as usize, s.span.end as usize);
98        box_metrics.push((a, b, pt(bx.metrics.height), pt(bx.metrics.depth)));
99    }
100    // Exact span plus nearest size finds the glyph run's own box instead of a containing box.
101    let own_box_split = |a: usize, b: usize, glyph_total: f64| -> Option<(f64, f64)> {
102        box_metrics
103            .iter()
104            .filter(|(ba, bb, _, _)| *ba == a && *bb == b)
105            .min_by(|(_, _, h1, d1), (_, _, h2, d2)| {
106                let e1 = (h1 + d1 - glyph_total).abs();
107                let e2 = (h2 + d2 - glyph_total).abs();
108                e1.total_cmp(&e2)
109            })
110            .map(|(_, _, h, d)| (*h, *d))
111    };
112    let mut leaves: Vec<(usize, usize, Rect)> = Vec::new();
113    let mut containers: Vec<(IrId, usize, usize, Rect)> = Vec::new();
114    for n in &fragment.nodes {
115        let Some(s) = n.primary_source else { continue };
116        let (a, b) = (s.span.start as usize, s.span.end as usize);
117        if a >= b {
118            continue;
119        }
120        // A zero area `Rule` draws nothing, so it carries no visible geometry.
121        if let LayoutNodeKind::Rule(r) = &n.kind {
122            if r.size.width.0 == 0 || r.size.height.0 == 0 {
123                continue;
124            }
125        }
126        // Spacing carries the last atom's span in the IR, so glue and kern never define geometry.
127        if matches!(n.kind, LayoutNodeKind::Glue(_) | LayoutNodeKind::Kern(_)) {
128            continue;
129        }
130        let mut rect = rect_of(fragment, &abs, n.id);
131        if matches!(
132            n.kind,
133            LayoutNodeKind::GlyphRun(_) | LayoutNodeKind::Rule(_) | LayoutNodeKind::Drawing(_)
134        ) {
135            // Recompute glyph vertical bounds from its own height and depth split.
136            if let LayoutNodeKind::GlyphRun(_) = &n.kind {
137                let glyph_total = pt(n.bounds.size.height);
138                if let (Some((h, d)), Some(o)) = (own_box_split(a, b, glyph_total), abs.get(&n.id))
139                {
140                    rect.y = pt(o.y) - h;
141                    rect.height = h + d;
142                }
143            }
144            leaves.push((a, b, rect));
145        } else {
146            // A `Box`'s own metrics give the above and below baseline split.
147            if let LayoutNodeKind::Box(bx) = &n.kind {
148                if let Some(o) = abs.get(&n.id) {
149                    rect.y = pt(o.y) - pt(bx.metrics.height);
150                    rect.height = pt(bx.metrics.height) + pt(bx.metrics.depth);
151                    // Childless boxes such as host boxes have empty bounds but a real advance width.
152                    rect.width = rect.width.max(pt(bx.metrics.width));
153                }
154            }
155            containers.push((n.id, a, b, rect));
156        }
157    }
158    // A container has no ink of its own if no leaf span is contained within it.
159    let is_leafless =
160        |ca: usize, cb: usize| !leaves.iter().any(|(la, lb, _)| *la >= ca && *lb <= cb);
161    let union_in = |range: &std::ops::Range<usize>| -> Option<Rect> {
162        let leaf_rects: Vec<Rect> = leaves
163            .iter()
164            .filter(|(a, b, _)| *a >= range.start && *b <= range.end)
165            .map(|(_, _, r)| *r)
166            .collect();
167        // Prefer deepest leafless containers so wrapper padding doesn't detach the caret.
168        let leafless: Vec<(IrId, Rect)> = containers
169            .iter()
170            .filter(|(_, a, b, _)| *a >= range.start && *b <= range.end)
171            .filter(|(_, a, b, _)| is_leafless(*a, *b))
172            .map(|(id, _, _, r)| (*id, *r))
173            .collect();
174        let mut has_leafless_descendant: std::collections::HashSet<IrId> =
175            std::collections::HashSet::new();
176        for (id, _) in &leafless {
177            let mut cur = *id;
178            while let Some(&p) = parent.get(&cur) {
179                if leafless.iter().any(|(cid, _)| *cid == p) {
180                    has_leafless_descendant.insert(p);
181                }
182                cur = p;
183            }
184        }
185        let leafless_deepest: Vec<Rect> = leafless
186            .iter()
187            .filter(|(id, _)| !has_leafless_descendant.contains(id))
188            .map(|(_, r)| *r)
189            .collect();
190        union(&[leaf_rects, leafless_deepest].concat())
191    };
192    let mut map = BoxMap::default();
193    for (&node, range) in &spans.node {
194        if let Some(r) = union_in(range) {
195            map.node.insert(node, r);
196        }
197    }
198    for (&seq, range) in &spans.seq {
199        if let Some(r) = union_in(range) {
200            map.seq.insert(seq, r);
201        }
202    }
203    map
204}
205
206/// Absolute origin of every node, accumulating parent relative `origin` down the box tree.
207fn absolute_origins(fragment: &Fragment) -> HashMap<IrId, IrPoint> {
208    let mut map = HashMap::new();
209    if let Some(root) = select_root(fragment) {
210        // mathtex-svg seeds the y axis with the surface baseline to match SVG viewBox coordinates.
211        let base = IrPoint {
212            x: mathtex_ir::Length(0),
213            y: fragment.surface.baseline,
214        };
215        walk_origins(fragment, root, base, &mut map);
216    }
217    // Nodes not reached from the root keep their own origin as a fallback.
218    for n in &fragment.nodes {
219        map.entry(n.id).or_insert(n.origin);
220    }
221    map
222}
223
224fn walk_origins(fragment: &Fragment, id: IrId, parent_abs: IrPoint, map: &mut HashMap<IrId, IrPoint>) {
225    let Some(node) = fragment.node(id) else { return };
226    let abs = IrPoint {
227        x: mathtex_ir::Length(parent_abs.x.0.saturating_add(node.origin.x.0)),
228        y: mathtex_ir::Length(parent_abs.y.0.saturating_add(node.origin.y.0)),
229    };
230    map.insert(id, abs);
231    for child in children_of(node) {
232        walk_origins(fragment, child, abs, map);
233    }
234}
235
236fn children_of(node: &LayoutNode) -> Vec<IrId> {
237    match &node.kind {
238        LayoutNodeKind::Box(b) => b.children.clone(),
239        LayoutNodeKind::List(l) => l.children.clone(),
240        LayoutNodeKind::Group { children } => children.clone(),
241        _ => Vec::new(),
242    }
243}
244
245/// The root box is the `Box` that no node lists as a child, matching mathtex-svg.
246fn select_root(fragment: &Fragment) -> Option<IrId> {
247    let mut is_child = std::collections::HashSet::new();
248    for n in &fragment.nodes {
249        for c in children_of(n) {
250            is_child.insert(c);
251        }
252    }
253    fragment.nodes.iter().find_map(|n| match n.kind {
254        LayoutNodeKind::Box(_) if !is_child.contains(&n.id) => Some(n.id),
255        _ => None,
256    })
257}
258
259fn rect_of(fragment: &Fragment, abs: &HashMap<IrId, IrPoint>, id: IrId) -> Rect {
260    let Some(n) = fragment.node(id) else {
261        return ZERO;
262    };
263    let o = abs.get(&id).copied().unwrap_or(n.origin);
264    // `o.y` is the node baseline in SVG coordinates, while bounds are TeX style baseline relative.
265    Rect {
266        x: pt(mathtex_ir::Length(o.x.0.saturating_add(n.bounds.origin.x.0))),
267        y: pt(mathtex_ir::Length(
268            o.y.0
269                .saturating_sub(n.bounds.origin.y.0)
270                .saturating_sub(n.bounds.size.height.0),
271        )),
272        width: pt(n.bounds.size.width),
273        height: pt(n.bounds.size.height),
274    }
275}
276
277/// Caret rect for a cursor, using the left neighbor or next item when at sequence start.
278pub fn caret_rect(boxes: &BoxMap, tree: &Tree, cursor: Cursor) -> Rect {
279    // Empty slot uses the phantom slot box.
280    if tree.is_empty(cursor.seq) {
281        return boxes
282            .seq
283            .get(&cursor.seq)
284            .map(|v| caret_at(v.x, v))
285            .unwrap_or(ZERO);
286    }
287    let items = tree.items(cursor.seq);
288    // Use the previous item at its right edge, else the next item at its left edge.
289    let placement = if cursor.index > 0 {
290        Some((items[cursor.index - 1], true))
291    } else if cursor.index < items.len() {
292        Some((items[cursor.index], false))
293    } else {
294        None
295    };
296    if let Some((node, right_edge)) = placement {
297        if let Some(v) = boxes.node.get(&node) {
298            return caret_at(if right_edge { v.x + v.width } else { v.x }, v);
299        }
300    }
301    boxes
302        .seq
303        .get(&cursor.seq)
304        .map(|v| caret_at(v.x, v))
305        .unwrap_or(ZERO)
306}
307
308/// A zero width caret at `x`, spanning the vertical extent of `v`.
309fn caret_at(x: f64, v: &Rect) -> Rect {
310    Rect {
311        x,
312        y: v.y,
313        width: 0.0,
314        height: v.height,
315    }
316}
317
318/// Highlight rects for a single seq selection run using the union of the run's node boxes.
319pub fn selection_rects(boxes: &BoxMap, tree: &Tree, sel: Selection) -> Vec<Rect> {
320    let lo = sel.anchor.min(sel.focus);
321    let hi = sel.anchor.max(sel.focus).min(tree.len(sel.seq));
322    let rects: Vec<Rect> = tree.items(sel.seq)[lo..hi]
323        .iter()
324        .filter_map(|n| boxes.node.get(n).copied())
325        .collect();
326    union(&rects).into_iter().collect()
327}
328
329/// Reverse hit test from a point to the model cursor position.
330pub fn hit_test(fragment: &Fragment, spans: &SpanMap, tree: &Tree, point: Point) -> Cursor {
331    hit_test_boxes(&match_boxes(spans, fragment), spans, tree, point)
332}
333
334/// Target resolved from a hit test point.
335#[derive(Clone, Copy)]
336enum Target {
337    Node(NodeId),
338    EmptySeq(SeqId),
339}
340
341/// Hit test over an already matched [`BoxMap`] for fallback testing without a real IR `Fragment`.
342fn hit_test_boxes(boxes: &BoxMap, spans: &SpanMap, tree: &Tree, point: Point) -> Cursor {
343    let mut best: Option<(Target, Rect, usize)> = None;
344    for (&node, &rect) in &boxes.node {
345        if contains(&rect, point) {
346            let span = spans.node.get(&node).map_or(usize::MAX, |r| r.end - r.start);
347            if best.as_ref().is_none_or(|(_, _, s)| span < *s) {
348                best = Some((Target::Node(node), rect, span));
349            }
350        }
351    }
352    // Empty seqs have no node, so their phantom placeholder box is the only direct hit target.
353    for (&seq, &rect) in &boxes.seq {
354        if tree.is_empty(seq) && contains(&rect, point) {
355            let span = spans.seq.get(&seq).map_or(usize::MAX, |r| r.end - r.start);
356            if best.as_ref().is_none_or(|(_, _, s)| span < *s) {
357                best = Some((Target::EmptySeq(seq), rect, span));
358            }
359        }
360    }
361    // Misses during drag resolve to the nearest box instead of jumping to document start.
362    let chosen = best
363        .map(|(t, r, _)| (t, r))
364        .or_else(|| nearest_target(boxes, tree, point));
365    if let Some((target, rect)) = chosen {
366        if let Some(c) = resolve_target(tree, target, rect, point) {
367            return c;
368        }
369    }
370    Cursor {
371        seq: tree.root(),
372        index: 0,
373    }
374}
375
376fn resolve_target(tree: &Tree, target: Target, rect: Rect, point: Point) -> Option<Cursor> {
377    match target {
378        Target::Node(node) => {
379            let (seq, idx) = tree.index_in_parent(node)?;
380            let mid = rect.x + rect.width / 2.0;
381            let index = if point.x > mid { idx + 1 } else { idx };
382            Some(Cursor { seq, index })
383        }
384        // An empty seq has exactly one position at its own start.
385        Target::EmptySeq(seq) => Some(Cursor { seq, index: 0 }),
386    }
387}
388
389/// The node or empty seq box nearest `point`, by squared distance to the rect's nearest edge.
390fn nearest_target(boxes: &BoxMap, tree: &Tree, point: Point) -> Option<(Target, Rect)> {
391    let nodes = boxes
392        .node
393        .iter()
394        .map(|(&n, &r)| (Target::Node(n), r, rect_dist2(&r, point)));
395    let empty_seqs = boxes
396        .seq
397        .iter()
398        .filter(|&(&s, _)| tree.is_empty(s))
399        .map(|(&s, &r)| (Target::EmptySeq(s), r, rect_dist2(&r, point)));
400    nodes
401        .chain(empty_seqs)
402        .min_by(|(_, _, a), (_, _, b)| a.total_cmp(b))
403        .map(|(t, r, _)| (t, r))
404}
405
406/// Squared distance from `p` to the nearest point on `r`, or 0 if `p` is inside.
407fn rect_dist2(r: &Rect, p: Point) -> f64 {
408    let cx = p.x.clamp(r.x, r.x + r.width);
409    let cy = p.y.clamp(r.y, r.y + r.height);
410    let (dx, dy) = (p.x - cx, p.y - cy);
411    dx * dx + dy * dy
412}
413
414const ZERO: Rect = Rect {
415    x: 0.0,
416    y: 0.0,
417    width: 0.0,
418    height: 0.0,
419};
420
421fn contains(r: &Rect, p: Point) -> bool {
422    p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height
423}
424fn union(rects: &[Rect]) -> Option<Rect> {
425    let first = rects.first()?;
426    let (mut x0, mut y0) = (first.x, first.y);
427    let (mut x1, mut y1) = (first.x + first.width, first.y + first.height);
428    for r in &rects[1..] {
429        x0 = x0.min(r.x);
430        y0 = y0.min(r.y);
431        x1 = x1.max(r.x + r.width);
432        y1 = y1.max(r.y + r.height);
433    }
434    Some(Rect {
435        x: x0,
436        y: y0,
437        width: x1 - x0,
438        height: y1 - y0,
439    })
440}
441
442/// Scaled points convert to points matching the SVG `viewBox` units.
443fn pt(len: mathtex_ir::Length) -> f64 {
444    len.0 as f64 / 65536.0
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::model::{MathClass, Symbol};
451
452    fn atom(c: &str) -> Symbol {
453        Symbol { latex: c.into(), class: MathClass::Ord }
454    }
455
456    /// Misses in gaps or vertical drag wobble must resolve to the nearest box, not document start.
457    #[test]
458    fn hit_test_falls_back_to_nearest_box_on_a_miss() {
459        let mut t = Tree::new();
460        let root = t.root();
461        t.insert_atom(Cursor { seq: root, index: 0 }, atom("a"));
462        t.insert_atom(Cursor { seq: root, index: 1 }, atom("b"));
463        let a = t.items(root)[0];
464        let b = t.items(root)[1];
465
466        let mut boxes = BoxMap::default();
467        boxes.node.insert(a, Rect { x: 0.0, y: 0.0, width: 1.0, height: 1.0 });
468        boxes.node.insert(b, Rect { x: 5.0, y: 0.0, width: 1.0, height: 1.0 });
469        let spans = SpanMap::default();
470
471        // In the gap, closer to `a`: lands just after it.
472        let c = hit_test_boxes(&boxes, &spans, &t, Point { x: 1.5, y: 0.5 });
473        assert_eq!(c, Cursor { seq: root, index: 1 });
474
475        // In the gap, closer to `b`: lands just before it.
476        let c = hit_test_boxes(&boxes, &spans, &t, Point { x: 4.0, y: 0.5 });
477        assert_eq!(c, Cursor { seq: root, index: 1 });
478
479        // Vertically off `a` during a drag still resolves near `a`, not the document start.
480        let c = hit_test_boxes(&boxes, &spans, &t, Point { x: 0.8, y: -5.0 });
481        assert_eq!(c, Cursor { seq: root, index: 1 });
482    }
483
484    #[test]
485    fn hit_test_on_a_truly_empty_box_map_defaults_to_document_start() {
486        let t = Tree::new();
487        let root = t.root();
488        let c = hit_test_boxes(&BoxMap::default(), &SpanMap::default(), &t, Point { x: 3.0, y: 3.0 });
489        assert_eq!(c, Cursor { seq: root, index: 0 });
490    }
491
492    /// Empty matrix cells must resolve through their own `boxes.seq` phantom placeholders.
493    #[test]
494    fn hit_test_lands_inside_empty_matrix_cells() {
495        let mut t = Tree::new();
496        let root = t.root();
497        let c = t.insert_matrix(Cursor { seq: root, index: 0 }, crate::model::MatrixEnv::Pmatrix, 2, 2);
498        let matrix = t.items(root)[0];
499        let cells = t.child_seqs(matrix); // Row major: [r0c0, r0c1, r1c0, r1c1]
500        assert_eq!(cells.len(), 4);
501        assert_eq!(c.seq, cells[0]); // insert_matrix lands in the first cell
502
503        let mut boxes = BoxMap::default();
504        boxes.node.insert(matrix, Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
505        boxes.seq.insert(cells[0], Rect { x: 1.0, y: 1.0, width: 3.0, height: 3.0 }); // Top left
506        boxes.seq.insert(cells[1], Rect { x: 6.0, y: 1.0, width: 3.0, height: 3.0 }); // Top right
507        boxes.seq.insert(cells[2], Rect { x: 1.0, y: 6.0, width: 3.0, height: 3.0 }); // Bottom left
508        boxes.seq.insert(cells[3], Rect { x: 6.0, y: 6.0, width: 3.0, height: 3.0 }); // Bottom right
509
510        // Realistic spans let each cell win the most specific tie break over the matrix box.
511        let mut spans = SpanMap::default();
512        spans.node.insert(matrix, 0..40);
513        for (i, &cell) in cells.iter().enumerate() {
514            spans.seq.insert(cell, i * 11..i * 11 + 11);
515        }
516
517        let hit = |x: f64, y: f64| hit_test_boxes(&boxes, &spans, &t, Point { x, y });
518        assert_eq!(hit(2.5, 2.5), Cursor { seq: cells[0], index: 0 });
519        assert_eq!(hit(7.5, 2.5), Cursor { seq: cells[1], index: 0 });
520        assert_eq!(hit(2.5, 7.5), Cursor { seq: cells[2], index: 0 });
521        assert_eq!(hit(7.5, 7.5), Cursor { seq: cells[3], index: 0 });
522    }
523
524    /// A non empty seq must resolve through its child node, not through `boxes.seq`.
525    #[test]
526    fn hit_test_prefers_node_over_a_non_empty_seqs_own_box() {
527        let mut t = Tree::new();
528        let root = t.root();
529        t.insert_atom(Cursor { seq: root, index: 0 }, atom("a"));
530        let a = t.items(root)[0];
531
532        let mut boxes = BoxMap::default();
533        boxes.node.insert(a, Rect { x: 0.0, y: 0.0, width: 2.0, height: 2.0 });
534        boxes.seq.insert(root, Rect { x: 0.0, y: 0.0, width: 2.0, height: 2.0 });
535        let spans = SpanMap::default();
536
537        // root is not empty because it holds "a", so this must resolve via the node.
538        let c = hit_test_boxes(&boxes, &spans, &t, Point { x: 0.5, y: 0.5 });
539        assert_eq!(c, Cursor { seq: root, index: 0 });
540    }
541}