···3434 /// Pixel-space `[x, y, w, h]`, fragment-relative byte range, and math flag
3535 /// for every glyph rendered from the fragment (preamble glyphs excluded).
3636 pub glyph_map: Vec<([f32; 4], Range<usize>, bool)>,
3737+ /// Fragment-relative byte ranges of every `Equation` node in the fragment.
3838+ pub math_spans: Vec<Range<usize>>,
3739}
38403941// ── Entry point ───────────────────────────────────────────────────────────────
···8688 height: pixmap.height(),
8789 blank_boxes,
8890 glyph_map,
9191+ math_spans,
8992 })
9093}
9194
+156-5
crates/tala/src/main.rs
···124124 blank_rects: Vec<[f64; 4]>,
125125 /// Normalized rect, fragment-relative byte range, is_in_math for every glyph.
126126 glyph_map: Vec<([f64; 4], std::ops::Range<usize>, bool)>,
127127+ /// Fragment-relative byte ranges of every equation in the source.
128128+ math_spans: Vec<std::ops::Range<usize>>,
127129}
128130129131#[component]
···141143 .unwrap_or_else(|e| Err(e.to_string()))
142144 });
143145144144- // Blank rects and glyph map synced from latest preview (needed in event closures).
146146+ // Blank rects, glyph map, and math spans synced from latest preview (needed in event closures).
145147 let mut blank_rects_sig = use_signal(|| Vec::<[f64; 4]>::new());
146148 let mut glyph_map_sig =
147149 use_signal(|| Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new());
150150+ let mut math_spans_sig = use_signal(|| Vec::<std::ops::Range<usize>>::new());
148151 use_effect(move || {
149152 if let Some(Ok(data)) = &*preview.read() {
150153 blank_rects_sig.set(data.blank_rects.clone());
151154 glyph_map_sig.set(data.glyph_map.clone());
155155+ math_spans_sig.set(data.math_spans.clone());
152156 }
153157 });
154158···325329 drawn_boxes.write().push(drawn_rect);
326330 return;
327331 }
328328- if hits.iter().any(|(_, _, in_math)| *in_math) {
329329- insert_error.set(Some("Math regions not supported yet — draw over plain text only.".into()));
332332+ let all_math = hits.iter().all(|(_, _, m)| *m);
333333+ let any_math = hits.iter().any(|(_, _, m)| *m);
334334+ let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap();
335335+ let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap();
336336+ if all_math {
337337+ let spans = math_spans_sig.read().clone();
338338+ let eq = spans.iter().find(|ms| ms.start <= min_start && max_end <= ms.end).cloned();
339339+ match eq {
340340+ None => {
341341+ insert_error.set(Some("Could not locate equation boundary.".into()));
342342+ drawn_boxes.write().push(drawn_rect);
343343+ }
344344+ Some(eq) => {
345345+ let cur = source.read().clone();
346346+ let open_len = if cur[eq.start..].starts_with("$ ") { 2 } else { 1 };
347347+ let close_len = if cur[..eq.end].ends_with(" $") { 2 } else { 1 };
348348+ let content_start = eq.start + open_len;
349349+ let content_end = eq.end - close_len;
350350+ let (exp_start, exp_end) = expand_math_selection(
351351+ &cur[content_start..content_end],
352352+ min_start - content_start,
353353+ max_end - content_start,
354354+ );
355355+ source.set(insert_blank_wrap_math(
356356+ &cur,
357357+ content_start + exp_start,
358358+ content_start + exp_end,
359359+ eq.start, eq.end,
360360+ ));
361361+ insert_error.set(None);
362362+ }
363363+ }
364364+ return;
365365+ }
366366+ if any_math {
367367+ insert_error.set(Some("Selection spans math and text — draw over one type only.".into()));
330368 drawn_boxes.write().push(drawn_rect);
331369 return;
332370 }
333333- let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap();
334334- let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap();
335371 let cur = source.read().clone();
336372 source.set(insert_blank_wrap(&cur, min_start, max_end));
337373 insert_error.set(None);
···369405}
370406371407408408+/// Given a raw glyph-based `sel_start..sel_end` within `eq_content` (the inner
409409+/// text of an equation, excluding `$` delimiters), expand the selection so it:
410410+/// 1. Extends `sel_end` to the end of any incomplete identifier token.
411411+/// 2. Includes a trailing `(...)` function-call argument if the token is followed by one.
412412+/// 3. Re-balances any unclosed `()`, `[]`, `{}` pairs.
413413+fn expand_math_selection(eq_content: &str, sel_start: usize, sel_end: usize) -> (usize, usize) {
414414+ let b = eq_content.as_bytes();
415415+ let len = b.len();
416416+ let mut end = sel_end.min(len);
417417+418418+ // 1. Expand to end of identifier/number token if sel_end is mid-token.
419419+ let is_alnum = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
420420+ if end > 0 && end < len && is_alnum(b[end - 1]) && is_alnum(b[end]) {
421421+ while end < len && is_alnum(b[end]) {
422422+ end += 1;
423423+ }
424424+ }
425425+426426+ // 2. If immediately followed by `(`, include the matched parenthesised group.
427427+ if end < len && b[end] == b'(' {
428428+ end += 1;
429429+ let mut depth = 1usize;
430430+ while end < len && depth > 0 {
431431+ match b[end] {
432432+ b'(' => depth += 1,
433433+ b')' => { depth -= 1; }
434434+ _ => {}
435435+ }
436436+ end += 1;
437437+ }
438438+ }
439439+440440+ // 3. Balance any unclosed delimiters in sel_start..end.
441441+ let (mut parens, mut brackets, mut braces) = (0i32, 0i32, 0i32);
442442+ for &c in &b[sel_start..end] {
443443+ match c {
444444+ b'(' => parens += 1, b')' => parens -= 1,
445445+ b'[' => brackets += 1, b']' => brackets -= 1,
446446+ b'{' => braces += 1, b'}' => braces -= 1,
447447+ _ => {}
448448+ }
449449+ }
450450+ while end < len && (parens > 0 || brackets > 0 || braces > 0) {
451451+ match b[end] {
452452+ b'(' => parens += 1, b')' => parens -= 1,
453453+ b'[' => brackets += 1, b']' => brackets -= 1,
454454+ b'{' => braces += 1, b'}' => braces -= 1,
455455+ _ => {}
456456+ }
457457+ end += 1;
458458+ }
459459+460460+ (sel_start, end)
461461+}
462462+463463+/// Split an equation at `sel_start..sel_end` (fragment-relative), wrapping the
464464+/// selection in `#blank[$...$]` and leaving the rest as separate `$...$` spans.
465465+/// `eq_start..eq_end` are the fragment-relative bounds of the full equation node
466466+/// (including the `$` delimiters). Empty left or right parts are omitted.
467467+fn insert_blank_wrap_math(
468468+ source: &str,
469469+ sel_start: usize,
470470+ sel_end: usize,
471471+ eq_start: usize,
472472+ eq_end: usize,
473473+) -> String {
474474+ // Detect display math (`$ ... $`) vs inline math (`$...$`).
475475+ let (open, close) = if source[eq_start..].starts_with("$ ") && source[..eq_end].ends_with(" $") {
476476+ ("$ ", " $")
477477+ } else {
478478+ ("$", "$")
479479+ };
480480+ let content_start = eq_start + open.len();
481481+ let content_end = eq_end - close.len();
482482+483483+ let left_inner = source[content_start..sel_start].trim_end();
484484+ let selected = source[sel_start..sel_end].trim();
485485+ let right_inner = source[sel_end..content_end].trim_start();
486486+487487+ let left_part = if left_inner.is_empty() { String::new() } else { format!("{}{}{} ", open, left_inner, close) };
488488+ let blank_part = format!("#blank[{}{}{}]", open, selected, close);
489489+ let right_part = if right_inner.is_empty() { String::new() } else { format!(" {}{}{}", open, right_inner, close) };
490490+491491+ format!("{}{}{}{}{}", &source[..eq_start], left_part, blank_part, right_part, &source[eq_end..])
492492+}
493493+494494+#[cfg(test)]
495495+mod tests {
496496+ use super::*;
497497+498498+ #[test]
499499+ fn expand_unbalanced_paren() {
500500+ // Glyph hits cover `e^(-x^2` but miss the closing `)`
501501+ let eq = "e^(-x^2)";
502502+ let (s, e) = expand_math_selection(eq, 0, 7); // 0..7 = "e^(-x^2"
503503+ assert_eq!(&eq[s..e], "e^(-x^2)");
504504+ }
505505+506506+ #[test]
507507+ fn expand_identifier_to_function_call() {
508508+ // Only `s` was hit (glyph for radical sign maps to first char of `sqrt`)
509509+ let eq = "x = sqrt(pi)";
510510+ let (s, e) = expand_math_selection(eq, 4, 5); // 4..5 = "s"
511511+ assert_eq!(&eq[s..e], "sqrt(pi)");
512512+ }
513513+514514+ #[test]
515515+ fn expand_no_change_when_already_complete() {
516516+ let eq = "a + b";
517517+ let (s, e) = expand_math_selection(eq, 4, 5); // "b"
518518+ assert_eq!(&eq[s..e], "b");
519519+ }
520520+}
521521+372522fn render_preview(source: &str) -> Result<PreviewData, String> {
373523 let deck_dir = PathBuf::from(std::env::temp_dir());
374524···463613 img_h: result.height,
464614 blank_rects,
465615 glyph_map,
616616+ math_spans: result.math_spans,
466617 })
467618}