diff --git a/src/documents/markdown.rs b/src/documents/markdown.rs index 9d6c3e3..e27c14b 100644 --- a/src/documents/markdown.rs +++ b/src/documents/markdown.rs @@ -1,4 +1,6 @@ -use std::{borrow::Cow, ops::Range}; +use std::ops::Range; + +mod tex; use gpui::App; use markdown::{Constructs, ParseOptions, mdast}; @@ -24,68 +26,11 @@ pub(crate) fn tree(text: &str) -> mdast::Node { } fn parsed(text: &str, options: &ParseOptions) -> mdast::Node { - let text = tex_math(text, options); - markdown::to_mdast(&text, options).expect("markdown without MDX always parses") -} - -/// `\(` and `\[` delimiters as `$`, byte for byte. -fn tex_math<'a>(text: &'a str, options: &ParseOptions) -> Cow<'a, str> { - if !text.contains("\\(") && !text.contains("\\[") { - return Cow::Borrowed(text); - } - let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses"); - let mut nodes = Vec::new(); - walk(&root, &mut nodes); - let mut kept = vec![false; text.len()]; - for node in nodes { - use mdast::Node::{Code, Html, InlineCode, InlineMath, Math, Toml, Yaml}; - if matches!( - node, - Code(_) | InlineCode(_) | Math(_) | InlineMath(_) | Html(_) | Yaml(_) | Toml(_) - ) { - kept[span(node)].fill(true); - } - } - let bytes = text.as_bytes(); - let mut out = bytes.to_vec(); - let mut open: Option<(usize, u8)> = None; - let mut at = 0; - while at + 1 < bytes.len() { - match bytes[at] { - // A blank line ends a paragraph and its formula. - b'\n' if blank_after(&bytes[at + 1..]) => open = None, - b'\\' if !kept[at] => { - match (bytes[at + 1], open) { - (b'(', None) => open = Some((at, b')')), - (b'[', None) => open = Some((at, b']')), - (close, Some((start, wanted))) - if close == wanted && at > start + 2 && !text[start..at].contains('$') => - { - out[start..start + 2].copy_from_slice(b"$ "); - out[at..at + 2].copy_from_slice(b" $"); - open = None; - } - _ => {} - } - // An escaped backslash escapes nothing after it. - at += 2; - continue; - } - _ => {} - } - at += 1; - } - match out == bytes { - true => Cow::Borrowed(text), - false => Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")), - } -} - -/// Whether the line `rest` starts holds only white space. -fn blank_after(rest: &[u8]) -> bool { - rest.iter() - .take_while(|byte| **byte != b'\n') - .all(u8::is_ascii_whitespace) + let swapped = tex::swapped(text, options); + let mut root = + markdown::to_mdast(&swapped.text, options).expect("markdown without MDX always parses"); + tex::mend(&mut root, &swapped); + root } /// An inline formula's TeX: inside `$` runs or `\(` pairs. diff --git a/src/documents/markdown/tex.rs b/src/documents/markdown/tex.rs new file mode 100644 index 0000000..fde53ab --- /dev/null +++ b/src/documents/markdown/tex.rs @@ -0,0 +1,266 @@ +//! `\(…\)` and `\[…\]` read as math between dollars. + +use std::{borrow::Cow, ops::Range}; + +use markdown::{ParseOptions, mdast, unist::Position}; + +use super::{span, walk}; +use crate::documents::tree::{Style, html}; + +/// Bytes a literal dollar may stand in as while parsing. +const STAND_INS: std::ops::RangeInclusive = 0x01..=0x08; + +/// One pair's delimiters as written. +pub(super) struct Pair { + open: Range, + close: Range, +} + +/// The swapped text and what to put back after parsing. +pub(super) struct Swapped<'a> { + pub text: Cow<'a, str>, + pairs: Vec, + /// Spaces that only part two dollars. + gaps: Vec, + /// The byte every other dollar was written as. + stand_in: u8, +} + +/// `text` with each pair's delimiters as dollars, byte for byte. +pub(super) fn swapped<'a>(text: &'a str, options: &ParseOptions) -> Swapped<'a> { + let stand_in = STAND_INS + .clone() + .find(|byte| !text.as_bytes().contains(byte)); + let none = || Swapped { + text: Cow::Borrowed(text), + pairs: Vec::new(), + gaps: Vec::new(), + stand_in: 0, + }; + if !text.contains("\\(") && !text.contains("\\[") { + return none(); + } + let Some(stand_in) = stand_in else { + log::warn!("markdown: \\( reads as text; every stand-in byte is used"); + return none(); + }; + let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses"); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + let mut kept = vec![false; text.len()]; + let (mut pairs, mut dollars) = (Vec::new(), Vec::new()); + for node in nodes { + use mdast::Node::{Heading, Paragraph, TableCell}; + if matches!(node, Paragraph(_) | Heading(_) | TableCell(_)) { + keep(node, text, &mut kept); + paired(text, span(node), &kept, &mut pairs, &mut dollars); + } + } + if pairs.is_empty() { + return none(); + } + let (out, gaps) = written(text, &pairs, &dollars, stand_in); + Swapped { + text: Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")), + pairs, + gaps, + stand_in, + } +} + +/// Marks what in `container` is not prose: code, tags, addresses. +fn keep(container: &mdast::Node, text: &str, kept: &mut [bool]) { + let mut nodes = Vec::new(); + walk(container, &mut nodes); + let mut code: Option<(String, usize)> = None; + for node in &nodes[1..] { + use mdast::Node::*; + let whole = span(node); + match node { + InlineCode(_) | InlineMath(_) | Image(_) | ImageReference(_) | FootnoteReference(_) => { + kept[whole].fill(true) + } + Link(_) | LinkReference(_) => { + kept[whole.clone()].fill(true); + // Bracketed text is prose; its address is not. + let last = node.children().and_then(|children| children.last()); + if text[whole.clone()].starts_with('[') + && let Some(last) = last + { + // A text's span skips a leading escape: start at `[`. + kept[whole.start + 1..span(last).end].fill(false); + } + } + Html(_) => { + kept[whole.clone()].fill(true); + let Some(tag) = html::tag(&text[whole.clone()]) else { + continue; + }; + match &code { + Some((name, from)) if tag.closing && tag.name == *name => { + kept[*from..whole.start].fill(true); + code = None; + } + None if !tag.closing && html::inline_style(&tag.name) == Some(Style::Code) => { + code = Some((tag.name, whole.end)); + } + _ => {} + } + } + _ => {} + } + } + // An unclosed `` runs to its block's end. + if let Some((_, from)) = code { + kept[from..span(container).end].fill(true); + } +} + +/// Pairs and bare dollars in `within`; pairs cross nothing kept. +fn paired( + text: &str, + within: Range, + kept: &[bool], + pairs: &mut Vec, + dollars: &mut Vec, +) { + let bytes = text.as_bytes(); + let mut open: Option<(usize, u8)> = None; + let mut at = within.start; + while at < within.end { + if kept[at] { + open = None; + } else if bytes[at] == b'$' { + dollars.push(at); + } else if bytes[at] == b'\\' && at + 1 < within.end { + match (bytes[at + 1], open) { + (b'(', None) => open = Some((at, b')')), + (b'[', None) => open = Some((at, b']')), + (close, Some((start, wanted))) if close == wanted && !kept[at + 1] => { + if !bytes[start + 2..at].iter().all(u8::is_ascii_whitespace) { + pairs.push(Pair { + open: start..start + 2, + close: at..at + 2, + }); + } + open = None; + } + _ => {} + } + // An escaped byte escapes nothing after it. + at += 2; + continue; + } + at += 1; + } +} + +/// The swapped bytes; a space parts dollars that would touch. +fn written(text: &str, pairs: &[Pair], dollars: &[usize], stand_in: u8) -> (Vec, Vec) { + let mut out = text.as_bytes().to_vec(); + for at in dollars { + out[*at] = stand_in; + } + let mut gaps = Vec::new(); + for (ix, pair) in pairs.iter().enumerate() { + for byte in &mut out[pair.open.end..pair.close.start] { + if *byte == b'$' { + *byte = stand_in; + } + } + let open = match pair.open.start.checked_sub(1).map(|before| out[before]) { + Some(b'$') => { + gaps.push(pair.open.start); + *b" $" + } + _ => *b"$ ", + }; + out[pair.open.clone()].copy_from_slice(&open); + let after = pair.close.end; + let opens_next = pairs + .get(ix + 1) + .is_some_and(|next| next.open.start == after); + let close = match opens_next || out.get(after) == Some(&b'$') { + true => { + gaps.push(pair.close.start + 1); + *b"$ " + } + false => *b" $", + }; + out[pair.close.clone()].copy_from_slice(&close); + } + (out, gaps) +} + +/// Puts TeX, spans and dollars back; drops parting spaces. +pub(super) fn mend(root: &mut mdast::Node, swapped: &Swapped) { + if swapped.pairs.is_empty() { + return; + } + if let mdast::Node::InlineMath(math) = root + && let Some(position) = &mut math.position + && let Some(pair) = + (swapped.pairs.iter()).find(|pair| pair.open.contains(&position.start.offset)) + { + math.value = written_tex(&math.value, pair, swapped, position); + position.start.column -= position.start.offset - pair.open.start; + position.start.offset = pair.open.start; + position.end.column += pair.close.end - position.end.offset; + position.end.offset = pair.close.end; + } + if let Some(children) = root.children_mut() { + children.iter_mut().for_each(|child| trim(child, swapped)); + children.retain(|child| !matches!(child, mdast::Node::Text(text) if text.value.is_empty())); + children.iter_mut().for_each(|child| mend(child, swapped)); + } +} + +/// The TeX as written, from the value markdown-rs read. +fn written_tex(value: &str, pair: &Pair, swapped: &Swapped, at: &Position) -> String { + let bytes = swapped.text.as_bytes(); + // A pad: the space a delimiter writes inside its dollar. + let pads = ( + at.start.offset == pair.open.start, + at.end.offset == pair.close.end, + ); + let ends = ( + pads.0 || bytes[pair.open.end] == b' ', + pads.1 || bytes[pair.close.start - 1] == b' ', + ); + // With spaces at both ends, markdown-rs trimmed one each. + let mut tex = match ends { + (true, true) => format!(" {value} "), + _ => value.to_string(), + }; + if pads.1 { + tex.pop(); + } + if pads.0 { + tex.remove(0); + } + tex.replace(char::from(swapped.stand_in), "$") +} + +/// Restores a text's dollars; drops a parting space at its ends. +fn trim(node: &mut mdast::Node, swapped: &Swapped) { + let mdast::Node::Text(text) = node else { + return; + }; + text.value = text.value.replace(char::from(swapped.stand_in), "$"); + let Some(position) = &mut text.position else { + return; + }; + if swapped.gaps.contains(&position.start.offset) && text.value.starts_with(' ') { + text.value.remove(0); + position.start.offset += 1; + position.start.column += 1; + } + if swapped.gaps.contains(&(position.end.offset - 1)) && text.value.ends_with(' ') { + text.value.pop(); + position.end.offset -= 1; + position.end.column -= 1; + } +} + +#[cfg(test)] +mod tests; diff --git a/src/documents/markdown/tex/tests.rs b/src/documents/markdown/tex/tests.rs new file mode 100644 index 0000000..b52911f --- /dev/null +++ b/src/documents/markdown/tex/tests.rs @@ -0,0 +1,99 @@ +use markdown::mdast::Node; + +use crate::documents::markdown::{options, span, tree, walk}; + +/// Each formula's TeX and span, and the text left. +fn read(source: &str) -> (Vec<(String, std::ops::Range)>, String) { + let root = tree(source); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + let mut maths = Vec::new(); + let mut text = String::new(); + for node in nodes { + match node { + Node::InlineMath(math) => maths.push((math.value.clone(), span(node))), + Node::Text(written) => text.push_str(&written.value), + _ => {} + } + } + (maths, text) +} + +fn tex(source: &str) -> Vec { + read(source).0.into_iter().map(|(tex, _)| tex).collect() +} + +fn untouched(source: &str) { + let plain = markdown::to_mdast(source, &options()).unwrap(); + assert_eq!(tree(source), plain, "{source:?}"); +} + +#[test] +fn a_pair_spans_its_delimiters_and_keeps_its_tex() { + assert_eq!( + read(r"a \(x\) b"), + (vec![("x".into(), 2..7)], "a b".into()) + ); + assert_eq!(tex(r"\( x \)"), [" x "]); + assert_eq!(tex(r"\(x \)\(y\)"), ["x ", "y"]); +} + +#[test] +fn adjacent_formulas_stay_two() { + for (source, maths) in [ + (r"\(x\)\(y\)", vec!["x", "y"]), + (r"$x$\(y\)", vec!["x", "y"]), + (r"\(y\)$x$", vec!["y", "x"]), + (r"\[x\]\(y\)", vec!["x", "y"]), + ] { + let (found, text) = read(source); + let found: Vec<_> = found.into_iter().map(|(tex, _)| tex).collect(); + assert_eq!(found, maths, "{source:?}"); + assert_eq!(text, "", "no parting space shows: {source:?}"); + } +} + +#[test] +fn dollars_stay_dollars() { + assert_eq!(tex(r"\(\$5\)"), [r"\$5"]); + assert_eq!(tex(r"\[a \$ b\]"), [r"a \$ b"]); + assert_eq!(tex(r"\(a$b\)"), ["a$b"]); + assert_eq!( + read(r"costs $5 and \(x\)"), + (vec![("x".into(), 13..18)], "costs $5 and ".into()), + "a stray dollar does not pair with a formula" + ); + assert_eq!(read(r"\$\(x\)").1, "$", "an escaped dollar before"); + assert_eq!(read(r"\(x\)\$").1, "$", "an escaped dollar after"); +} + +#[test] +fn addresses_keep_their_parentheses() { + let link = r"[go](https://e.test/\(x\))"; + untouched(link); + untouched(r"![p](pics/\(x\).png)"); + untouched("[go][a\\(x\\)]\n\n[a\\(x\\)]: /u"); + untouched(r""); + assert_eq!(tex(r"[\(x\)](https://e.test)"), ["x"], "link text is prose"); + untouched(r"[a\(b](c)\)"); +} + +#[test] +fn code_and_tags_stay_literal() { + untouched(r"a \(x\) b"); + untouched(r"a \(x\) b"); + untouched(r"a \(x\) b"); + untouched(r"`\(x\)`"); + untouched(r"\(a `b` c\)"); + assert_eq!(tex(r"\(x\)"), ["x"], "other tags hold prose"); +} + +#[test] +fn a_pair_stays_in_one_block() { + untouched("- \\(a\n- b\\)"); + untouched("\\(a\r\rb\\)"); + untouched("\\(a\r\n\r\nb\\)"); + untouched("# \\(a\nb\\)"); + untouched("| \\(a | b\\) |\n| - | - |"); + assert_eq!(tex("> \\(a\n> b\\)"), ["a\nb"], "a quote's lines"); +} diff --git a/src/documents/render.rs b/src/documents/render.rs index 6e91ec1..6fd9dd0 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -1,4 +1,4 @@ -use std::{path::Path, rc::Rc, sync::Arc}; +use std::{ops::Range, path::Path, rc::Rc, sync::Arc}; use gpui::{ AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, @@ -171,10 +171,13 @@ fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle { } } -/// `inline` as text; `hidden` ranges lose their highlight. +/// Highlights laid over a text's own. +type Over = Vec<(Range, HighlightStyle)>; + +/// `inline` as text; hidden ranges lose highlight, `over` adds some. fn drawn_text( inline: &Inline, - hidden: &[std::ops::Range], + (hidden, over): (&[Range], Over), links: Option, on_link: &Option, cx: &App, @@ -185,6 +188,7 @@ fn drawn_text( let styles: Vec<_> = (inline.styles.iter()) .filter(|(range, _)| !hidden.contains(range)) .map(|(range, style)| (range.clone(), highlight(style, colors, rule))) + .chain(over) .collect(); let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); let layout = styled.layout().clone(); @@ -233,7 +237,8 @@ impl Draw<'_> { fn inline(&self, inline: &Inline, cx: &App) -> AnyElement { let links = (!inline.links.is_empty()).then(|| self.key("links")); if !inline.styles.iter().any(|(_, style)| *style == Style::Math) { - let (drawn, layout) = drawn_text(inline, &[], links, self.hooks.on_link, cx); + let (drawn, layout) = + drawn_text(inline, (&[], Vec::new()), links, self.hooks.on_link, cx); let at = move |position| { layout .index_for_position(position) diff --git a/src/documents/render/math_text.rs b/src/documents/render/math_text.rs index c2021f7..f2d255e 100644 --- a/src/documents/render/math_text.rs +++ b/src/documents/render/math_text.rs @@ -3,14 +3,15 @@ use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; use gpui::{ - AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId, - IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size, TextLayout, - TextStyleRefinement, Window, point, + AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, HighlightStyle, + InspectorElementId, IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size, + TextLayout, TextStyleRefinement, Window, point, }; use super::{OnLink, drawn_text}; use crate::{ documents::tree::{Inline, Style}, + theme::ActiveTheme, typography::typeset, }; @@ -36,6 +37,8 @@ pub(super) struct MathText { text: Option, /// Each picture, its size, and the drawn span it covers. pictures: Vec<(Arc, Size, Range)>, + /// One glue character's width. + glue: Pixels, } impl MathText { @@ -65,6 +68,7 @@ impl MathText { shown, text: None, pictures: Vec::new(), + glue: Pixels::ZERO, }; (text, at) } @@ -100,14 +104,14 @@ fn back(swaps: &[Swap], at: usize) -> usize { (at as isize + shift) as usize } -/// `inline` with each swapped span drawn as `glue[ix]` glue. -fn shown(inline: &Inline, swaps: &mut [Swap], glue: &[usize]) -> Inline { +/// `inline` with each swapped span drawn as `drawn_as[ix]`. +fn shown(inline: &Inline, swaps: &mut [Swap], drawn_as: &[String]) -> Inline { let mut text = String::new(); let mut from = 0; - for ((written, drawn), count) in swaps.iter_mut().zip(glue) { + for ((written, drawn), with) in swaps.iter_mut().zip(drawn_as) { text.push_str(&inline.text[from..written.start]); let start = text.len(); - text.extend(std::iter::repeat_n(GLUE, *count)); + text.push_str(with); *drawn = start..text.len(); from = written.end; } @@ -166,9 +170,8 @@ impl Element for MathText { .map(|(range, _)| range.clone()) .collect(); maths.sort_by_key(|range| range.start); - let mut swaps = Vec::new(); - let mut counts = Vec::new(); - let mut pictures = Vec::new(); + let (mut swaps, mut drawn_as) = (Vec::new(), Vec::new()); + let (mut pictures, mut errors) = (Vec::new(), Vec::new()); for (ix, range) in maths.into_iter().enumerate() { let source = SharedString::from(self.inline.text[range.clone()].to_string()); match typeset( @@ -179,22 +182,42 @@ impl Element for MathText { cx, ) { Some(Ok((image, size))) => { - counts.push((size.width / width).ceil().max(1.0) as usize); - pictures.push((image, size)); + let count = (size.width / width).ceil().max(1.0) as usize; + drawn_as.push(GLUE.to_string().repeat(count)); + pictures.push((image, size, swaps.len())); swaps.push((range, 0..0)); } // The formula is the user's text: not logged. - Some(Err(_)) => log::warn!("markdown: an inline formula shows as TeX, unset"), + Some(Err(error)) => { + log::warn!("markdown: an inline formula shows as TeX, unset"); + drawn_as.push(format!(" ({error})")); + errors.push(swaps.len()); + swaps.push((range.end..range.end, 0..0)); + } None => {} } } - let inline = shown(&self.inline, &mut swaps, &counts); - let hidden: Vec> = swaps.iter().map(|(_, drawn)| drawn.clone()).collect(); - let (mut text, layout) = - drawn_text(&inline, &hidden, self.links.clone(), &self.on_link, cx); + let inline = shown(&self.inline, &mut swaps, &drawn_as); + let hidden: Vec> = (pictures.iter()) + .map(|(_, _, ix)| swaps[*ix].1.clone()) + .collect(); + let danger = HighlightStyle { + color: Some(cx.theme().colors.danger), + ..HighlightStyle::default() + }; + let said = (errors.iter()) + .map(|ix| (swaps[*ix].1.clone(), danger)) + .collect(); + let (mut text, layout) = drawn_text( + &inline, + (&hidden, said), + self.links.clone(), + &self.on_link, + cx, + ); let tallest = pictures .iter() - .map(|(_, size)| size.height) + .map(|(_, size, _)| size.height) .fold(Pixels::ZERO, Pixels::max); // A tall formula opens the paragraph's lines. let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement { @@ -202,9 +225,10 @@ impl Element for MathText { ..TextStyleRefinement::default() }); let id = window.with_text_style(taller, |window| text.request_layout(window, cx)); - self.pictures = (pictures.into_iter().zip(&hidden)) - .map(|((image, size), drawn)| (image, size, drawn.clone())) + self.pictures = (pictures.into_iter().zip(hidden)) + .map(|((image, size, _), drawn)| (image, size, drawn)) .collect(); + self.glue = width; *self.shown.borrow_mut() = Some(Shown { layout, swaps }); self.text = Some(text); (id, ()) @@ -244,18 +268,9 @@ impl Element for MathText { }; let line = layout.line_height(); for (image, size, drawn) in &self.pictures { - let (Some(start), Some(end)) = ( - layout.position_for_index(drawn.start), - layout.position_for_index(drawn.end), - ) else { + let Some((start, room)) = place(layout, drawn, self.glue) else { continue; }; - // Glue at a line's end ends there. - let room = if end.y == start.y { - end.x - start.x - } else { - size.width - }; let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0); let bounds = Bounds { origin, @@ -270,6 +285,22 @@ impl Element for MathText { } } +/// Where glue `drawn` starts, and how wide it runs. +fn place( + layout: &TextLayout, + drawn: &Range, + glue: Pixels, +) -> Option<(Point, Pixels)> { + let last = layout.position_for_index(drawn.end - GLUE.len_utf8())?; + let first = layout.position_for_index(drawn.start)?; + // A wrap's index reads as the previous line's end. + let start = match first.y == last.y { + true => first, + false => point(layout.bounds().origin.x, last.y), + }; + Some((start, last.x + glue - start.x)) +} + #[cfg(test)] mod tests { use super::*; @@ -282,7 +313,7 @@ mod tests { ..Inline::default() }; let mut swaps = vec![(2..5, 0..0)]; - let drawn = shown(&inline, &mut swaps, &[4]); + let drawn = shown(&inline, &mut swaps, &[GLUE.to_string().repeat(4)]); assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4))); let glue = GLUE.len_utf8() * 4; assert_eq!(swaps[0].1, 2..2 + glue); @@ -296,60 +327,107 @@ mod tests { assert_eq!(forward(&swaps, 6), 3 + glue); } - type Found = Rc< - RefCell< - Option<( - Rc>>, - Box) -> usize>, - )>, - >, - >; + type Read = Box) -> usize>; + type Found = Rc>>, Read)>>>; - struct Probe(Found); + /// Draws `text` with `math` a formula, `width` wide. + struct Probe { + text: &'static str, + math: Range, + width: f32, + found: Found, + } impl gpui::Render for Probe { fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { use gpui::{ParentElement as _, Styled as _}; let inline = Inline { - text: "a x^2 b".into(), - styles: vec![(2..5, Style::Math)], + text: self.text.into(), + styles: vec![(self.math.clone(), Style::Math)], ..Inline::default() }; let (text, at) = MathText::new("probe".into(), inline, None, None); - *self.0.borrow_mut() = Some((text.shown.clone(), Box::new(at))); - gpui::div().text_size(gpui::px(16.0)).child(text) + *self.found.borrow_mut() = Some((text.shown.clone(), Box::new(at))); + let shell = gpui::div() + .w(gpui::px(self.width)) + .text_size(gpui::px(16.0)); + shell.child(text) } } - #[gpui::test] - fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) { + fn laid( + text: &'static str, + math: Range, + width: f32, + cx: &mut gpui::TestAppContext, + ) -> Found { cx.update(crate::theme::Theme::init); let found = Found::default(); let held = found.clone(); - let (_, cx) = cx.add_window_view(move |_, _| Probe(held)); + let (_, cx) = cx.add_window_view(move |_, _| Probe { + text, + math, + width, + found: held, + }); for _ in 0..4 { cx.run_until_parked(); cx.update(|window, _| window.refresh()); } + found + } + + #[gpui::test] + fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) { + let found = laid("a x^2 b", 2..5, 400.0, cx); let found = found.borrow(); let (shown, at) = found.as_ref().expect("drawn"); let shown = shown.borrow(); let Shown { layout, swaps } = shown.as_ref().expect("laid out"); assert_eq!(swaps.len(), 1, "the formula typeset in place"); - let drawn = swaps[0].1.clone(); - let (start, end) = ( - layout.position_for_index(drawn.start), - layout.position_for_index(drawn.end), - ); - let (start, end) = (start.expect("its start"), end.expect("its end")); - assert!( - end.x - start.x > gpui::px(10.0), - "glue holds the picture's width" - ); - let middle = point( - (start.x + end.x) / 2.0, - start.y + layout.line_height() / 2.0, - ); + let drawn = &swaps[0].1; + let at_x = |ix| layout.position_for_index(ix).expect("on its line").x; + let glue = at_x(drawn.end) - at_x(drawn.end - GLUE.len_utf8()); + let (start, room) = place(layout, drawn, glue).expect("placed"); + assert!(room > gpui::px(10.0), "glue holds the picture's width"); + let middle = point(start.x + room / 2.0, start.y + layout.line_height() / 2.0); assert_eq!(at(middle), 2, "a press on the picture is the formula"); } + + #[gpui::test] + fn a_formula_wrapped_whole_draws_on_its_line(cx: &mut gpui::TestAppContext) { + let text = "aaaaaaaaaa x^2+y^2=r^2 tail"; + let found = laid(text, 11..22, 160.0, cx); + let found = found.borrow(); + let (shown, at) = found.as_ref().expect("drawn"); + let shown = shown.borrow(); + let Shown { layout, swaps } = shown.as_ref().expect("laid out"); + let drawn = &swaps[0].1; + let line = layout.line_height(); + let last = layout.position_for_index(drawn.end - GLUE.len_utf8()); + assert_eq!(last.expect("its glue").y, layout.bounds().top() + line); + let (start, _) = place(layout, drawn, gpui::px(0.0)).expect("placed"); + assert_eq!( + start, + point(layout.bounds().left(), layout.bounds().top() + line), + "the picture starts the second line" + ); + assert_eq!(at(start + point(gpui::px(2.0), line / 2.0)), 11); + } + + #[gpui::test] + fn a_failed_formula_says_why_after_its_tex(cx: &mut gpui::TestAppContext) { + let found = laid("a x^2^3 b", 2..7, 400.0, cx); + let found = found.borrow(); + let (shown, _) = found.as_ref().expect("drawn"); + let shown = shown.borrow(); + let Shown { layout, swaps } = shown.as_ref().expect("laid out"); + assert_eq!(swaps[0].0, 7..7, "the TeX stays; the reason follows"); + let text = layout.wrapped_text(); + assert!( + text.starts_with("a x^2^3 (") && text.ends_with(") b"), + "{text:?}" + ); + assert!(text.contains(" at "), "where RaTeX stopped: {text:?}"); + } } diff --git a/src/documents/tree/mod.rs b/src/documents/tree/mod.rs index 179766b..39be760 100644 --- a/src/documents/tree/mod.rs +++ b/src/documents/tree/mod.rs @@ -6,7 +6,7 @@ use super::markdown::{span, tree, walk}; mod blocks; mod emoji; -mod html; +pub(crate) mod html; mod html_block; mod sources; mod stack; @@ -318,7 +318,7 @@ impl<'a> Reader<'a> { } } - /// Text split at its images; lone `$$` or `\[` math is block. + /// Text splits at images; a lone formula is block. fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec) { if let [mdast::Node::InlineMath(math)] = children && ["$$", "\\["] diff --git a/src/typography/math.rs b/src/typography/math.rs index 55239a7..947eadd 100644 --- a/src/typography/math.rs +++ b/src/typography/math.rs @@ -193,7 +193,7 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size), Ok((wrapped(svg, [left, top, w, h])?, size(px(w), px(h)))) } -/// `svg`'s drawing under a root viewing `[left, top, w, h]` in px. +/// `svg` drawn in a px root viewing `[left, top, w, h]`. fn wrapped(svg: &str, [left, top, w, h]: [f32; 4]) -> Result { let open = svg .find('>')