Documents: \( pairs stay in one paragraph and off addresses; formulas place at wraps
CI / platforms (ubuntu-24.04) (push) Waiting to run
CI / site (push) Successful in 36s
CI / platforms (windows-latest) (push) Successful in 4m49s
CI / crate (push) Failing after 11m31s

- Pairs pair inside one paragraph, heading or cell; links' addresses,
  images, code and <code>/<kbd> text stay literal.
- Adjacent formulas stay two; a parting space is dropped after parsing.
- Stray and escaped dollars, in or out of a formula, stay dollars.
- A formula's node spans its delimiters; its value is the written TeX.
- A formula wrapped whole draws at its own line's start.
- A failed inline formula shows its reason after the TeX.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude committed 2026-10-18 23:54:39 -04:00
1 parent e095f06689
commit a20e712e29
7 files changed
+524 -131

No files matched your search

+8 -63
View File
@@ -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.
+266
View File
@@ -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<u8> = 0x01..=0x08;
/// One pair's delimiters as written.
pub(super) struct Pair {
open: Range<usize>,
close: Range<usize>,
}
/// The swapped text and what to put back after parsing.
pub(super) struct Swapped<'a> {
pub text: Cow<'a, str>,
pairs: Vec<Pair>,
/// Spaces that only part two dollars.
gaps: Vec<usize>,
/// 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 `<code>` 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<usize>,
kept: &[bool],
pairs: &mut Vec<Pair>,
dollars: &mut Vec<usize>,
) {
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<u8>, Vec<usize>) {
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;
+99
View File
@@ -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<usize>)>, 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<String> {
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"<https://e.test/\(x\)>");
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 <code>\(x\)</code> b");
untouched(r"a <kbd>\(x\)</kbd> b");
untouched(r"a <code>\(x\) b");
untouched(r"`\(x\)`");
untouched(r"\(a `b` c\)");
assert_eq!(tex(r"<b>\(x\)</b>"), ["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");
}
+9 -4
View File
@@ -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<usize>, HighlightStyle)>;
/// `inline` as text; hidden ranges lose highlight, `over` adds some.
fn drawn_text(
inline: &Inline,
hidden: &[std::ops::Range<usize>],
(hidden, over): (&[Range<usize>], Over),
links: Option<ElementId>,
on_link: &Option<OnLink>,
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)
+139 -61
View File
@@ -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<AnyElement>,
/// Each picture, its size, and the drawn span it covers.
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
/// 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<Range<usize>> = 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<Range<usize>> = (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<usize>,
glue: Pixels,
) -> Option<(Point<Pixels>, 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<RefCell<Option<Shown>>>,
Box<dyn Fn(Point<Pixels>) -> usize>,
)>,
>,
>;
type Read = Box<dyn Fn(Point<Pixels>) -> usize>;
type Found = Rc<RefCell<Option<(Rc<RefCell<Option<Shown>>>, Read)>>>;
struct Probe(Found);
/// Draws `text` with `math` a formula, `width` wide.
struct Probe {
text: &'static str,
math: Range<usize>,
width: f32,
found: Found,
}
impl gpui::Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> 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<usize>,
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:?}");
}
}
+2 -2
View File
@@ -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<Node>) {
if let [mdast::Node::InlineMath(math)] = children
&& ["$$", "\\["]
+1 -1
View File
@@ -193,7 +193,7 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>),
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<String, MathError> {
let open = svg
.find('>')