Documents: \( pairs stay in one paragraph and off addresses; formulas place at wraps
- 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:
7 files changed
+524
-131
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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".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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
&& ["$$", "\\["]
|
||||
|
||||
@@ -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('>')
|
||||
|
||||
Reference in new issue
Block a user