diff --git a/src/documents/document/format/clear.rs b/src/documents/document/format/clear.rs index 02d5e3b..0a31876 100644 --- a/src/documents/document/format/clear.rs +++ b/src/documents/document/format/clear.rs @@ -5,7 +5,7 @@ use std::ops::Range; use markdown::mdast::Node; use super::{Change, EditRefusal, after, block, cut, holder, marks, room}; -use crate::documents::{markdown::span, tree::inside}; +use crate::documents::markdown::{math_inside, span}; /// The backslashes of escapes in a text node's span. fn escapes(text: &str, whole: Range) -> Vec> { @@ -33,7 +33,7 @@ fn meta(node: &Node, text: &str, autolinks: &[Range]) -> Vec Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(), Node::Text(_) => escapes(text, whole), Node::InlineMath(_) => { - let inner = inside(text, whole.clone(), '$'); + let inner = math_inside(text, whole.clone()); vec![whole.start..inner.start, inner.end..whole.end] } Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end], diff --git a/src/documents/markdown.rs b/src/documents/markdown.rs index b246e4d..9d6c3e3 100644 --- a/src/documents/markdown.rs +++ b/src/documents/markdown.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::{borrow::Cow, ops::Range}; use gpui::App; use markdown::{Constructs, ParseOptions, mdast}; @@ -20,19 +20,92 @@ pub(crate) fn options() -> ParseOptions { /// `text` as a syntax tree. pub(crate) fn tree(text: &str) -> mdast::Node { - markdown::to_mdast(text, &options()).expect("markdown without MDX always parses") + parsed(text, &options()) +} + +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) +} + +/// An inline formula's TeX: inside `$` runs or `\(` pairs. +pub(crate) fn math_inside(source: &str, whole: Range) -> Range { + match source[whole.clone()].starts_with('$') { + true => crate::documents::tree::inside(source, whole, '$'), + false => whole.start + 2..whole.end - 2, + } } /// How a whole file parses: front matter too. -pub fn file_options() -> ParseOptions { +pub(crate) fn file_options() -> ParseOptions { let mut options = options(); options.constructs.frontmatter = true; options } /// A whole file as a syntax tree. -pub(crate) fn file_tree(text: &str) -> mdast::Node { - markdown::to_mdast(text, &file_options()).expect("markdown without MDX always parses") +pub fn file_tree(text: &str) -> mdast::Node { + parsed(text, &file_options()) } /// The bytes of the source a parsed node spans. diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 1a0c525..77621af 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -46,7 +46,7 @@ pub(crate) use history::Change; pub use history::{PageHistoryDiff, Version, VersionHistory}; pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed}; pub use link::LinkEditor; -pub use markdown::file_options as markdown_options; +pub use markdown::file_tree as markdown_tree; pub use markdown::markdown_highlights; pub use modes::{MarkdownEditor, MarkdownMode}; pub use pages::{PageCover, PageIcon}; diff --git a/src/documents/render.rs b/src/documents/render.rs index 681ede1..6e91ec1 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -3,7 +3,7 @@ use std::{path::Path, rc::Rc, sync::Arc}; use gpui::{ AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled, - StyledText, UnderlineStyle, Window, div, prelude::*, relative, + StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative, }; use super::{ @@ -21,6 +21,7 @@ use crate::{ }; mod blocks; +mod math_text; mod point; mod table; @@ -170,6 +171,38 @@ fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle { } } +/// `inline` as text; `hidden` ranges lose their highlight. +fn drawn_text( + inline: &Inline, + hidden: &[std::ops::Range], + links: Option, + on_link: &Option, + cx: &App, +) -> (AnyElement, TextLayout) { + let theme = cx.theme(); + let colors = &theme.colors; + let rule = theme.underline_thickness(); + let styles: Vec<_> = (inline.styles.iter()) + .filter(|(range, _)| !hidden.contains(range)) + .map(|(range, style)| (range.clone(), highlight(style, colors, rule))) + .collect(); + let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); + let layout = styled.layout().clone(); + let drawn = match links { + None => styled.into_any_element(), + Some(id) => { + let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip(); + let on_link = on_link.clone(); + InteractiveText::new(id, styled) + .on_click(ranges, move |ix, window, cx| { + open_link(&on_link, &urls[ix], window, cx) + }) + .into_any_element() + } + }; + (drawn, layout) +} + /// Draws nodes under one owner's ids and hooks. struct Draw<'a> { id: &'a ElementId, @@ -198,30 +231,20 @@ impl Draw<'_> { } fn inline(&self, inline: &Inline, cx: &App) -> AnyElement { - let theme = cx.theme(); - let colors = &theme.colors; - let rule = theme.underline_thickness(); - let styles: Vec<_> = inline - .styles - .iter() - .map(|(range, style)| (range.clone(), highlight(style, colors, rule))) - .collect(); - let styled = - StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); - let layout = styled.layout().clone(); - let drawn = match inline.links.is_empty() { - true => styled.into_any_element(), - false => { - let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip(); - let on_link = self.hooks.on_link.clone(); - InteractiveText::new(self.key("links"), styled) - .on_click(ranges, move |ix, window, cx| { - open_link(&on_link, &urls[ix], window, cx) - }) - .into_any_element() - } - }; - self.point_text(drawn, layout, inline) + 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 at = move |position| { + layout + .index_for_position(position) + .unwrap_or_else(|near| near) + }; + return self.point_text(drawn, at, inline); + } + let on_link = self.hooks.on_link.clone(); + let (drawn, at) = + math_text::MathText::new(self.key("math"), inline.clone(), links, on_link); + self.point_text(drawn.into_any_element(), at, inline) } fn nodes(&self, nodes: &[Node], cx: &App) -> Vec { diff --git a/src/documents/render/math_text.rs b/src/documents/render/math_text.rs new file mode 100644 index 0000000..c2021f7 --- /dev/null +++ b/src/documents/render/math_text.rs @@ -0,0 +1,355 @@ +//! Inline formulas typeset among a paragraph's words. + +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, +}; + +use super::{OnLink, drawn_text}; +use crate::{ + documents::tree::{Inline, Style}, + typography::typeset, +}; + +/// Glue that reserves a formula's width and never breaks. +const GLUE: char = '\u{00A0}'; + +/// An original span and the drawn span standing for it. +type Swap = (Range, Range); + +/// The drawn text and how its bytes map back. +struct Shown { + layout: TextLayout, + swaps: Vec, +} + +/// Paragraph text whose formulas draw typeset over reserved glue. +pub(super) struct MathText { + id: ElementId, + inline: Inline, + links: Option, + on_link: Option, + shown: Rc>>, + text: Option, + /// Each picture, its size, and the drawn span it covers. + pictures: Vec<(Arc, Size, Range)>, +} + +impl MathText { + /// The element and how a point finds its original byte. + pub(super) fn new( + id: ElementId, + inline: Inline, + links: Option, + on_link: Option, + ) -> (Self, impl Fn(Point) -> usize + 'static) { + let shown = Rc::new(RefCell::new(None)); + let read = shown.clone(); + let at = move |position| match &*read.borrow() { + Some(Shown { layout, swaps }) => back( + swaps, + layout + .index_for_position(position) + .unwrap_or_else(|near| near), + ), + None => 0, + }; + let text = Self { + id, + inline, + links, + on_link, + shown, + text: None, + pictures: Vec::new(), + }; + (text, at) + } +} + +/// Where original byte `at` is drawn. +fn forward(swaps: &[Swap], at: usize) -> usize { + let mut shift = 0isize; + for (written, drawn) in swaps { + if at < written.start { + break; + } + if at < written.end { + return drawn.start; + } + shift = drawn.end as isize - written.end as isize; + } + (at as isize + shift) as usize +} + +/// The original byte drawn byte `at` stands for. +fn back(swaps: &[Swap], at: usize) -> usize { + let mut shift = 0isize; + for (written, drawn) in swaps { + if at < drawn.start { + break; + } + if at < drawn.end { + return written.start; + } + shift = written.end as isize - drawn.end as isize; + } + (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 { + let mut text = String::new(); + let mut from = 0; + for ((written, drawn), count) in swaps.iter_mut().zip(glue) { + text.push_str(&inline.text[from..written.start]); + let start = text.len(); + text.extend(std::iter::repeat_n(GLUE, *count)); + *drawn = start..text.len(); + from = written.end; + } + text.push_str(&inline.text[from..]); + let span = |range: &Range| forward(swaps, range.start)..forward(swaps, range.end); + Inline { + text, + styles: (inline.styles.iter()) + .map(|(range, style)| (span(range), *style)) + .collect(), + links: (inline.links.iter()) + .map(|(range, url)| (span(range), url.clone())) + .collect(), + sources: Vec::new(), + } +} + +impl IntoElement for MathText { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for MathText { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let style = window.text_style(); + let rem = window.rem_size(); + let font = style.font_size.to_pixels(rem); + let glue = String::from(GLUE); + let run = style.to_run(glue.len()); + let width = window + .text_system() + .shape_line(glue.into(), font, &[run], None) + .width; + let mut maths: Vec> = (self.inline.styles.iter()) + .filter(|(_, style)| *style == Style::Math) + .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(); + for (ix, range) in maths.into_iter().enumerate() { + let source = SharedString::from(self.inline.text[range.clone()].to_string()); + match typeset( + (self.id.clone(), SharedString::from(format!("formula-{ix}"))), + (&source, false), + font, + window, + cx, + ) { + Some(Ok((image, size))) => { + counts.push((size.width / width).ceil().max(1.0) as usize); + pictures.push((image, size)); + 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"), + 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 tallest = pictures + .iter() + .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 { + line_height: Some(tallest.into()), + ..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())) + .collect(); + *self.shown.borrow_mut() = Some(Shown { layout, swaps }); + self.text = Some(text); + (id, ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + if let Some(text) = &mut self.text { + text.prepaint(window, cx); + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let Some(text) = &mut self.text else { + return; + }; + text.paint(window, cx); + let shown = self.shown.borrow(); + let Some(Shown { layout, .. }) = &*shown else { + return; + }; + 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 { + 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, + size: *size, + }; + if let Err(error) = + window.paint_image(bounds, bounds, Corners::default(), image.clone(), 0, false) + { + log::error!("markdown: an inline formula did not paint: {error}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_map_both_ways_around_a_formula() { + let inline = Inline { + text: "a x^2 b".into(), + styles: vec![(2..5, Style::Math), (6..7, Style::Strong)], + ..Inline::default() + }; + let mut swaps = vec![(2..5, 0..0)]; + let drawn = shown(&inline, &mut swaps, &[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); + assert_eq!( + drawn.styles[1].0, + 3 + glue..4 + glue, + "the bold b moves past the glue" + ); + assert_eq!(back(&swaps, 3), 2, "glue reads back to its formula"); + assert_eq!(back(&swaps, 3 + glue), 6); + assert_eq!(forward(&swaps, 6), 3 + glue); + } + + type Found = Rc< + RefCell< + Option<( + Rc>>, + Box) -> usize>, + )>, + >, + >; + + struct Probe(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)], + ..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) + } + } + + #[gpui::test] + fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) { + cx.update(crate::theme::Theme::init); + let found = Found::default(); + let held = found.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Probe(held)); + for _ in 0..4 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + 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, + ); + assert_eq!(at(middle), 2, "a press on the picture is the formula"); + } +} diff --git a/src/documents/render/point.rs b/src/documents/render/point.rs index 27a256e..c201d96 100644 --- a/src/documents/render/point.rs +++ b/src/documents/render/point.rs @@ -140,11 +140,11 @@ impl Draw<'_> { }) } - /// Drawn `inline`; a press finds its byte through `layout`. + /// Drawn `inline`; a press finds its byte through `at`. pub(super) fn point_text( &self, element: AnyElement, - layout: TextLayout, + at: impl Fn(gpui::Point) -> usize + 'static, inline: &Inline, ) -> AnyElement { if self.hooks.on_point.is_none() { @@ -152,9 +152,7 @@ impl Draw<'_> { } let inline = Rc::new(inline.clone()); self.pointable(element, move |event| { - let at = layout - .index_for_position(event.position) - .unwrap_or_else(|near| near); + let at = at(event.position); let link = inline.links.iter().find(|(range, _)| range.contains(&at)); Pointed { link: link.map(|(_, url)| url.clone()), diff --git a/src/documents/tree/mod.rs b/src/documents/tree/mod.rs index 35f5cc4..179766b 100644 --- a/src/documents/tree/mod.rs +++ b/src/documents/tree/mod.rs @@ -318,12 +318,14 @@ impl<'a> Reader<'a> { } } - /// Text split at its images; lone `$$` math is block. + /// Text split at its images; lone `$$` or `\[` math is block. fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec) { if let [mdast::Node::InlineMath(math)] = children - && self.source[span(node)].starts_with("$$") + && ["$$", "\\["] + .iter() + .any(|open| self.source[span(node)].starts_with(open)) { - return out.push(Node::Math(math.value.clone())); + return out.push(Node::Math(math.value.trim().to_string())); } let mut run = Run { table: matches!(node, mdast::Node::TableCell(_)), diff --git a/src/documents/tree/sources.rs b/src/documents/tree/sources.rs index 2e62fc9..b3e248f 100644 --- a/src/documents/tree/sources.rs +++ b/src/documents/tree/sources.rs @@ -32,7 +32,10 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range, Escape Some(match node { Node::Text(_) => (whole, Escapes::Markdown), Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain), - Node::InlineMath(_) => (inside(source, whole, '$'), Escapes::Plain), + Node::InlineMath(_) => ( + crate::documents::markdown::math_inside(source, whole), + Escapes::Plain, + ), _ => (whole, Escapes::Plain), }) } diff --git a/src/documents/tree/tests.rs b/src/documents/tree/tests.rs index 8b6aa00..8021190 100644 --- a/src/documents/tree/tests.rs +++ b/src/documents/tree/tests.rs @@ -416,3 +416,41 @@ fn the_first_shortcode_decides_its_text_node() { assert_eq!(line(source).text, shown, "{source:?}"); } } + +#[test] +fn latex_delimiters_read_as_math() { + assert_eq!(parse(r"\[c = 1\]"), [Node::Math("c = 1".into())]); + assert_eq!(parse("\\[\nc = 1\n\\]"), [Node::Math("c = 1".into())]); + let Node::Paragraph(inline) = &parse(r"so \(c\) holds")[0] else { + panic!("inline math stays in its paragraph") + }; + assert_eq!(inline.text, "so c holds"); + assert_eq!(inline.styles, [(3..4, Style::Math)]); + let Node::Paragraph(alone) = &parse(r"\(c\)")[0] else { + panic!("a lone formula stays inline") + }; + assert_eq!(alone.styles, [(0..1, Style::Math)]); +} + +#[test] +fn latex_delimiters_leave_code_escapes_and_open_pairs() { + let text = |source: &str| match &parse(source)[0] { + Node::Paragraph(inline) => (inline.text.clone(), inline.styles.clone()), + other => panic!("a paragraph, not {other:?}"), + }; + assert_eq!(text(r"`\(c\)`").1, [(0..5, Style::Code)], "code stays code"); + assert_eq!( + text(r"\\(c\\)"), + (r"\(c\)".into(), Vec::new()), + "escaped backslashes" + ); + assert_eq!(text(r"a \(c").0, "a (c", "no closing, no formula"); + let split = parse("a \\(c\n\nd\\) e"); + assert!( + split.iter().all(|node| match node { + Node::Paragraph(inline) => inline.styles.is_empty(), + _ => false, + }), + "a blank line ends the formula: {split:?}" + ); +} diff --git a/src/typography/math.rs b/src/typography/math.rs index 95b9655..3de1557 100644 --- a/src/typography/math.rs +++ b/src/typography/math.rs @@ -96,7 +96,7 @@ struct Formula { } /// A typeset formula: its picture and logical size. -type Typeset = Result<(Arc, Size), MathError>; +pub(crate) type Typeset = Result<(Arc, Size), MathError>; enum Typesetter {} @@ -201,27 +201,39 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size), Ok((reframed, size(px(w), px(h)))) } -impl Latex { - /// The asset key this formula draws from in `window`. - fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) { - let theme = cx.theme(); - let font = theme.text_size(self.size).to_pixels(window.rem_size()); - let formula = Formula { - source: self.source.clone(), - display: self.display, - rgba: u32::from(theme.colors.fg.to_rgb()), - font: (font.as_f32() * 100.0).round() as u32, - scale: (window.scale_factor() * 100.0).round() as u32, - }; - (formula, font) +/// The asset key for `source` at `font` in `window`. +fn formula( + (source, display): (&SharedString, bool), + font: Pixels, + window: &Window, + cx: &App, +) -> Formula { + Formula { + source: source.clone(), + display, + rgba: u32::from(cx.theme().colors.fg.to_rgb()), + font: (font.as_f32() * 100.0).round() as u32, + scale: (window.scale_factor() * 100.0).round() as u32, } } +/// `source` typeset at `font` in `window`; None while it loads. +pub(crate) fn typeset( + id: impl Into, + (source, display): (&SharedString, bool), + font: Pixels, + window: &mut Window, + cx: &mut App, +) -> Option { + let formula = formula((source, display), font, window, cx); + let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone()); + hold::(id, &formula, picture, window, cx) +} + impl RenderOnce for Latex { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let (formula, font) = self.formula(window, cx); - let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone()); - match hold::(self.id, &formula, picture, window, cx) { + let font = cx.theme().text_size(self.size).to_pixels(window.rem_size()); + match typeset(self.id, (&self.source, self.display), font, window, cx) { Some(Ok((picture, shape))) => img(picture) .w(shape.width) .h(shape.height) diff --git a/src/typography/math/tests.rs b/src/typography/math/tests.rs index 92c2a7d..1371f85 100644 --- a/src/typography/math/tests.rs +++ b/src/typography/math/tests.rs @@ -114,8 +114,11 @@ fn flush(cx: &mut gpui::VisualTestContext) { /// Whether `source`'s picture sits in the asset cache. fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool { cx.update(|window, cx| { - let latex = Latex::parse("probe", source).expect("parses"); - let (formula, _) = latex.formula(window, cx); + let font = cx + .theme() + .text_size(TextSize::Lg) + .to_pixels(window.rem_size()); + let formula = super::formula((&source.to_string().into(), false), font, window, cx); cx.has_asset::(&formula) }) } diff --git a/src/typography/mod.rs b/src/typography/mod.rs index ee87ce0..cf12778 100644 --- a/src/typography/mod.rs +++ b/src/typography/mod.rs @@ -26,6 +26,7 @@ pub use formatted::{ pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo}; pub use link::{ExternalLink, Link}; pub(crate) use math::failed; +pub(crate) use math::typeset; pub use math::{Latex, MathError, formula_svg}; pub use rolling::AnimatedNumber; pub use select::SelectableText;