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