From ab9a14c69233611180ca88a7e131284d8c591d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 23:59:56 -0400 Subject: [PATCH] Markdown pictures: freed with their elements, glyphs whole, failures keep the source, file URLs parsed, Mermaid labels in Ely's ink Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- examples/gallery/pages/typography/values.rs | 11 +- src/documents/blocks/content.rs | 2 +- src/documents/diagram.rs | 36 ++-- src/documents/picture.rs | 89 ++++++---- src/documents/render.rs | 12 +- src/documents/tests.rs | 12 ++ src/primitives/held.rs | 53 ++++++ src/primitives/mod.rs | 2 + src/typography/math.rs | 183 ++++++++++++-------- src/typography/math/tests.rs | 140 +++++++++++++++ src/typography/mod.rs | 1 + 12 files changed, 417 insertions(+), 126 deletions(-) create mode 100644 src/primitives/held.rs create mode 100644 src/typography/math/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 18d713c..b67c19e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. - Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. -- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. +- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. - Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle. - Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. The Showcase tab lists apps built with Ely from the public repo `ZacharyZhang-NY/Ely-GPUI-Showcases` on git.zacharyzhang.com (Gitea): manifests, PNG previews and Markdown READMEs, checked by its `scripts/catalog.mjs` (`marked`, `pngjs`, `twitter-text`), its stars refreshed by its CI. The mark is the letter E in iso blocks, its middle arm in the accent: one geometry in `src/iso/mark.ts`, drawn by the engine the miniatures use, worn by the header in the page's iso colors and by the favicon. Below 768px the header shows the mark without its word, which stays the home link's name; below 414px it drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' Gitea mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143). diff --git a/examples/gallery/pages/typography/values.rs b/examples/gallery/pages/typography/values.rs index 3860b4f..b599882 100644 --- a/examples/gallery/pages/typography/values.rs +++ b/examples/gallery/pages/typography/values.rs @@ -201,7 +201,8 @@ pub fn emoji(cx: &App) -> impl IntoElement + use<> { } pub fn latex(cx: &App) -> impl IntoElement + use<> { - let math = |source: &str| Latex::parse(source).expect("gallery formula parses"); + let math = + |id: &'static str, source: &str| Latex::parse(id, source).expect("gallery formula parses"); section( "Latex / MathInline", "TeX typeset by RaTeX, in the text's color.", @@ -210,17 +211,17 @@ pub fn latex(cx: &App) -> impl IntoElement + use<> { .child( specimens() .gap_10() - .child(specimen("euler", math(r"e^{i\pi} + 1 = 0"), cx)) + .child(specimen("euler", math("euler", r"e^{i\pi} + 1 = 0"), cx)) .child(specimen( "sum", - math(r"\sum_{k=1}^{n} k = \frac{n(n+1)}{2}"), + math("sum", r"\sum_{k=1}^{n} k = \frac{n(n+1)}{2}"), cx, )) .child(specimen( "root", - math(r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"), + math("root", r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"), cx, )), ) - .child(code("Latex::parse(r\"e^{i\\pi} + 1 = 0\")?", cx)) + .child(code("Latex::parse(\"euler\", r\"e^{i\\pi} + 1 = 0\")?", cx)) } diff --git a/src/documents/blocks/content.rs b/src/documents/blocks/content.rs index 35e8afc..2636755 100644 --- a/src/documents/blocks/content.rs +++ b/src/documents/blocks/content.rs @@ -159,7 +159,7 @@ impl BlockEditor { } BlockKind::Math => { let tex = block.fields[0].read(cx).text().to_string(); - let shown = match Latex::parse(&tex) { + let shown = match Latex::parse((id.clone(), format!("math-{key}")), &tex) { _ if tex.trim().is_empty() => div().into_any_element(), Ok(math) => div() .flex() diff --git a/src/documents/diagram.rs b/src/documents/diagram.rs index feca4ed..46e599f 100644 --- a/src/documents/diagram.rs +++ b/src/documents/diagram.rs @@ -4,22 +4,27 @@ use std::{ }; use gpui::{ - App, Asset, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString, - Size, Styled, Window, div, img, px, size, + App, Asset, ElementId, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, + SharedString, Size, Styled, Window, div, img, px, size, }; use mermaid_rs_renderer::{LayoutConfig, RenderOptions, Theme, render_strict}; -use crate::theme::{ActiveTheme, Radius, TextSize}; +use crate::{ + primitives::hold, + theme::{ActiveTheme, Radius, TextSize}, +}; /// A Mermaid diagram; a failure shows why and the source. #[derive(IntoElement)] pub struct Diagram { + id: ElementId, source: SharedString, } impl Diagram { - pub fn new(source: impl Into) -> Self { + pub fn new(id: impl Into, source: impl Into) -> Self { Self { + id: id.into(), source: source.into(), } } @@ -66,9 +71,14 @@ fn svg(chart: &Chart) -> Result { true => Theme::dark(), false => Theme::modern(), }; - theme.background = chart.ground.to_string(); - theme.edge_label_background = chart.ground.to_string(); - theme.text_color = chart.ink.to_string(); + let (ground, ink) = (chart.ground.to_string(), chart.ink.to_string()); + theme.background = ground.clone(); + theme.edge_label_background = ground; + theme.primary_text_color = ink.clone(); + theme.text_color = ink.clone(); + theme.pie_title_text_color = ink.clone(); + theme.pie_section_text_color = ink.clone(); + theme.pie_legend_text_color = ink; let options = RenderOptions { theme, layout: LayoutConfig::default(), @@ -114,7 +124,8 @@ impl RenderOnce for Diagram { theme.text_size(TextSize::Sm), theme.mono_family.clone(), ); - match window.use_asset::(&chart, cx) { + let picture = |drawn: &Drawn| drawn.as_ref().ok().map(|(image, _)| image.clone()); + match hold::(self.id, &chart, picture, window, cx) { Some(Ok((picture, shape))) => div() .flex() .justify_center() @@ -157,9 +168,12 @@ mod tests { #[test] fn a_flowchart_draws_with_the_ground_and_ink() { - let svg = svg(&chart("flowchart LR\n A --> B", true)).expect("draws"); - assert!(svg.contains(" B", true)).expect("draws"); + let svg = svg.to_lowercase(); + assert!(svg.contains("#2c2c2c"), "the ground: {svg}"); + let label = svg.find(">start<").expect("the label"); + let tag = &svg[svg[..label].rfind(") -> Origin { if lower.starts_with("data:") { return inline(url); } - if let Some(rest) = url.strip_prefix("file://") { - let rest = rest.strip_prefix("localhost").unwrap_or(rest); - // Windows writes file:///C:/… - let rest = match rest.as_bytes() { - [b'/', _, b':', ..] if cfg!(windows) => &rest[1..], - _ => rest, - }; - return path(rest).map_or_else(Origin::Bad, Origin::File); + if lower.starts_with("file:") { + return file(&url[5..]); } let scheme = url.split_once(':').is_some_and(|(scheme, _)| { scheme.len() > 1 @@ -88,6 +82,24 @@ pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin { } } +/// A `file:` address after its scheme: host, path, no query. +fn file(rest: &str) -> Origin { + let rest = rest.split(['?', '#']).next().unwrap_or_default(); + let (host, written) = match rest.strip_prefix("//") { + Some(after) => after.split_at(after.find('/').unwrap_or(after.len())), + None => ("", rest), + }; + let local = host.is_empty() || host.eq_ignore_ascii_case("localhost"); + let written = match (local, written.as_bytes()) { + // Windows writes file:///C:/… + (true, [b'/', _, b':', ..]) if cfg!(windows) => written[1..].to_string(), + (true, _) => written.to_string(), + (false, _) if cfg!(windows) => format!("//{host}{written}"), + (false, _) => return Origin::Bad(format!("files on {host} are not read").into()), + }; + path(&written).map_or_else(Origin::Bad, Origin::File) +} + /// A `data:` address: base64 bytes of an image type. fn inline(url: &str) -> Origin { let Some((head, payload)) = url[5..].split_once(',') else { @@ -128,6 +140,7 @@ impl Asset for Unpacker { /// The picture at `url`, natural size up to the column. #[derive(IntoElement)] pub(crate) struct Picture { + pub id: ElementId, pub alt: String, pub url: String, /// The folder relative addresses start from. @@ -136,32 +149,32 @@ pub(crate) struct Picture { impl RenderOnce for Picture { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - picture(&self.alt, &self.url, self.base.as_deref(), window, cx) + picture(self, window, cx) } } -fn picture( - alt: &str, - url: &str, - base: Option<&Path>, - window: &mut Window, - cx: &mut App, -) -> AnyElement { +fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement { + let Picture { id, alt, url, base } = shown; + let (alt, url) = (alt.as_str(), url.as_str()); let colors = cx.theme().colors.clone(); - let (loaded, svg) = match origin(url, base) { + let (loaded, svg) = match origin(url, base.as_deref()) { Origin::File(path) => { let svg = path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")); let resource = Resource::Path(path.into()); - let loaded = window - .use_asset::(&resource, cx) + let image = |loaded: &Result, _>| loaded.as_ref().ok().cloned(); + let loaded = hold::(id, &resource, image, window, cx) .map(|loaded| loaded.map_err(|error| SharedString::from(error.to_string()))); (loaded, svg) } Origin::Data(format, bytes) => { let svg = format == ImageFormat::Svg; - (window.use_asset::(&(format, bytes), cx), svg) + let image = |loaded: &Result, _>| loaded.as_ref().ok().cloned(); + ( + hold::(id, &(format, bytes), image, window, cx), + svg, + ) } Origin::Web => { let said = i18n::text(cx, "markdown.picture.web", &[]); @@ -240,19 +253,35 @@ mod tests { } #[test] - fn file_addresses_decode() { - assert_eq!( - origin("file:///tmp/a%23b.png", None), - Origin::File(PathBuf::from("/tmp/a#b.png")) - ); + fn file_addresses_decode_without_query_or_fragment() { + for url in [ + "file:///tmp/a%23b.png", + "file://localhost/tmp/a%23b.png#x", + "FILE:/tmp/a%23b.png?y", + ] { + assert_eq!( + origin(url, None), + Origin::File(PathBuf::from("/tmp/a#b.png")), + "{url}" + ); + } if cfg!(windows) { assert_eq!( - origin("file:///C:/notes/a.png", None), + origin("file:///C:/notes/a.png#preview", None), Origin::File(PathBuf::from("C:/notes/a.png")) ); } } + #[test] + fn a_file_on_another_host_is_its_share_or_refused() { + let found = origin("file://server/share/a.png", Some(Path::new("/notes"))); + match cfg!(windows) { + true => assert_eq!(found, Origin::File(PathBuf::from("//server/share/a.png"))), + false => assert!(matches!(found, Origin::Bad(_)), "{found:?}"), + } + } + #[test] fn data_addresses_carry_their_bytes() { assert_eq!( diff --git a/src/documents/render.rs b/src/documents/render.rs index 9418508..e41a538 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -17,7 +17,7 @@ use crate::{ navigation::{Anchor, Sections}, primitives::{FocusRing as _, Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, - typography::Latex, + typography::{Latex, failed}, }; type OnLink = Rc; @@ -214,7 +214,7 @@ impl Draw<'_> { .into_any_element(), Node::Code { language, text } => { if language.as_deref() == Some("mermaid") { - return Diagram::new(text.clone()).into_any_element(); + return Diagram::new(self.key("diagram"), text.clone()).into_any_element(); } if let Some(draw) = self.code { return draw( @@ -351,16 +351,14 @@ impl Draw<'_> { .flex() .justify_center() .py_2() - .child(match Latex::parse(tex) { + .child(match Latex::parse(self.key("math"), tex) { Ok(math) => math.display().into_any_element(), - Err(error) => div() - .text_color(colors.danger) - .child(error.to_string()) - .into_any_element(), + Err(error) => failed(&error, tex, cx), }) .into_any_element(), Node::Image { alt, url, link } => { let picture = div().id(self.key("image")).child(Picture { + id: self.key("picture"), alt: alt.clone(), url: url.clone(), base: self.base.clone(), diff --git a/src/documents/tests.rs b/src/documents/tests.rs index f5e88e8..e6fc9a8 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -358,3 +358,15 @@ fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestA fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) { assert_eq!(drawn_picture(400.0, cx), size(px(200.0), px(100.0))); } + +#[gpui::test] +fn a_formula_that_fails_shows_why_and_its_source(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Page { + source: "$$x^2^3$$\n", + base: std::env::temp_dir(), + width: 300.0, + }); + settle(cx); + assert!(cx.debug_bounds("latex-error").is_some(), "the failure drew"); +} diff --git a/src/primitives/held.rs b/src/primitives/held.rs new file mode 100644 index 0000000..09845cc --- /dev/null +++ b/src/primitives/held.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use gpui::{App, Asset, ElementId, RenderImage, Window}; + +/// An element's asset; freed on change or when it leaves. +pub(crate) struct Held { + source: Option, + image: Option>, +} + +impl Held { + fn free(&mut self, cx: &mut App) { + if let Some(source) = self.source.take() { + cx.remove_asset::(&source); + } + if let Some(image) = self.image.take() { + // The window may be drawing; free after it. + cx.defer(move |cx| cx.drop_image(image, None)); + } + } +} + +/// `source` as `A` for element `id`; the previous one freed. +pub(crate) fn hold( + id: impl Into, + source: &A::Source, + image: impl Fn(&A::Output) -> Option>, + window: &mut Window, + cx: &mut App, +) -> Option +where + A::Source: PartialEq, +{ + let held = window.use_keyed_state(id, cx, |_, cx| { + cx.on_release(|held: &mut Held, cx| held.free(cx)) + .detach(); + Held { + source: None, + image: None, + } + }); + let loaded = window.use_asset::(source, cx); + held.update(cx, |held, cx| { + if held.source.as_ref() != Some(source) { + held.free(cx); + held.source = Some(source.clone()); + } + if let Some(loaded) = &loaded { + held.image = image(loaded); + } + }); + loaded +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 0060ab0..5890333 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -5,6 +5,7 @@ mod divider; mod files; mod focus; mod ghost; +mod held; mod icon; mod image; mod layer; @@ -28,6 +29,7 @@ pub(crate) use focus::{ }; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; pub use ghost::DragGhost; +pub(crate) use held::hold; pub use icon::{Icon, IconName}; pub use image::Image; pub(crate) use image::{checked_ratio, framed}; diff --git a/src/typography/math.rs b/src/typography/math.rs index 16b82de..1532259 100644 --- a/src/typography/math.rs +++ b/src/typography/math.rs @@ -5,39 +5,67 @@ use std::{ }; use gpui::{ - App, Asset, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString, Size, - Styled, Window, div, img, px, size, + AnyElement, App, Asset, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, + RenderImage, RenderOnce, SharedString, Size, Styled, Window, div, img, px, size, }; use ratex_layout::{LayoutOptions, layout, to_display_list}; use ratex_svg::{SvgOptions, render_to_svg}; use ratex_types::{color::Color, math_style::MathStyle}; -use crate::theme::{ActiveTheme, TextSize}; +use crate::{ + primitives::hold, + theme::{ActiveTheme, TextSize}, +}; -/// Why a formula did not typeset: RaTeX's own words. +/// Why a formula did not typeset, and where. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct MathError(SharedString); +pub struct MathError { + message: SharedString, + /// The offset RaTeX names, when it names one. + at: Option, +} impl fmt::Display for MathError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) + match self.at { + Some(at) => write!(f, "{} at {at}", self.message), + None => f.write_str(&self.message), + } } } impl std::error::Error for MathError {} -/// TeX typeset by RaTeX, in the text's color. +impl MathError { + fn said(message: impl Into) -> Self { + Self { + message: message.into(), + at: None, + } + } +} + +fn parsed(source: &str) -> Result, MathError> { + ratex_parser::parse(source).map_err(|error| MathError { + message: error.message.into(), + at: error.loc.map(|loc| loc.start), + }) +} + +/// TeX typeset by RaTeX; a failure shows why and source. #[derive(IntoElement)] pub struct Latex { + id: ElementId, source: SharedString, size: TextSize, display: bool, } impl Latex { - pub fn parse(source: &str) -> Result { - ratex_parser::parse(source).map_err(|error| MathError(error.message.into()))?; + pub fn parse(id: impl Into, source: &str) -> Result { + parsed(source)?; Ok(Self { + id: id.into(), source: source.to_string().into(), size: TextSize::Lg, display: false, @@ -82,16 +110,15 @@ impl Asset for Typesetter { let (svg, shape) = svg(&formula)?; let picture = renderer .render_single_frame(svg.as_bytes(), formula.scale as f32 / 100.0) - .map_err(|error| MathError(format!("{error}").into()))?; + .map_err(|error| MathError::said(format!("{error}")))?; Ok((picture, shape)) } } } -/// The formula as SVG, glyphs outlined, and its size. +/// The formula as outlined SVG holding all ink; its size. fn svg(formula: &Formula) -> Result<(String, Size), MathError> { - let ast = - ratex_parser::parse(&formula.source).map_err(|error| MathError(error.message.into()))?; + let ast = parsed(&formula.source)?; let [r, g, b, a] = formula .rgba .to_be_bytes() @@ -103,8 +130,7 @@ fn svg(formula: &Formula) -> Result<(String, Size), MathError> { }) .with_color(Color::new(r, g, b, a)); let font = f64::from(formula.font) / 100.0; - // RaTeX may panic on input it parsed. - let laid = catch_unwind(AssertUnwindSafe(|| { + let laid = guarded(|| { let list = to_display_list(&layout(&ast, &options)); let svg = render_to_svg( &list, @@ -116,80 +142,95 @@ fn svg(formula: &Formula) -> Result<(String, Size), MathError> { font_dir: String::new(), }, ); - (list.width, list.height + list.depth, svg) - })); - let (width, height, svg) = laid.map_err(|_| { - log::error!("latex: RaTeX failed to lay out {:?}", formula.source); - MathError("RaTeX could not lay out this formula".into()) + (list.width * font, (list.height + list.depth) * font, svg) })?; - let shape = size(px((width * font) as f32), px((height * font) as f32)); - Ok((svg, shape)) + let (width, height, svg) = laid; + framed(&svg, width as f32, height as f32) } -impl RenderOnce for Latex { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { +/// Runs RaTeX's layout; a panic becomes an error. +fn guarded(lay: impl FnOnce() -> T) -> Result { + catch_unwind(AssertUnwindSafe(lay)).map_err(|_| { + // The formula is the user's text: not logged. + log::error!("latex: RaTeX failed to lay out a formula"); + MathError::said("RaTeX could not lay out this formula") + }) +} + +/// `svg` reframed around its ink and layout box. +fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size), MathError> { + let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default()) + .map_err(|error| MathError::said(format!("{error}")))?; + let ink = tree.root().abs_stroke_bounding_box(); + let left = ink.left().min(0.0); + let top = ink.top().min(0.0); + let right = ink.right().max(width); + let bottom = ink.bottom().max(height); + let open = svg + .find('>') + .ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?; + let close = svg + .rfind("") + .ok_or_else(|| MathError::said("RaTeX left the svg open"))?; + let (w, h) = (right - left, bottom - top); + let reframed = format!( + "{}", + &svg[open + 1..close] + ); + 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 (color, danger) = (theme.colors.fg, theme.colors.danger); 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(color.to_rgb()), + 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, }; - match window.use_asset::(&formula, cx) { + (formula, font) + } +} + +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) { Some(Ok((picture, shape))) => img(picture) .w(shape.width) .h(shape.height) .into_any_element(), - Some(Err(error)) => div() - .text_color(danger) - .child(error.to_string()) - .into_any_element(), + Some(Err(error)) => failed(&error, &self.source, cx), None => div().h(font).into_any_element(), } } } -#[cfg(test)] -mod tests { - use super::*; - - fn formula(source: &str, display: bool) -> Formula { - Formula { - source: source.to_string().into(), - display, - rgba: 0x112233ff, - font: 1600, - scale: 200, - } - } - - #[test] - fn a_formula_typesets_to_outlined_svg_in_its_color() { - let (svg, shape) = svg(&formula(r"\frac{a}{b} + \sqrt{x}", false)).expect("typesets"); - assert!(svg.starts_with(" px(16.) && shape.height > px(16.), "{shape:?}"); - } - - #[test] - fn display_style_sets_limits_larger() { - let (_, text) = svg(&formula(r"\sum_{k=1}^{n} k", false)).expect("typesets"); - let (_, display) = svg(&formula(r"\sum_{k=1}^{n} k", true)).expect("typesets"); - assert!(display.height > text.height, "{display:?} over {text:?}"); - } - - #[test] - fn bad_tex_says_why() { - let Err(error) = Latex::parse(r"\frac{a}") else { - panic!("a missing argument parses") - }; - assert!(!error.to_string().is_empty()); - assert!(Latex::parse(r"x^{2").is_err(), "an open brace"); - } +/// A formula that did not typeset: why, then its source. +pub(crate) fn failed(error: &MathError, source: &str, cx: &App) -> AnyElement { + let theme = cx.theme(); + div() + .debug_selector(|| "latex-error".into()) + .flex() + .flex_col() + .child( + div() + .text_color(theme.colors.danger) + .child(error.to_string()), + ) + .child( + div() + .font_family(theme.mono_family.clone()) + .text_color(theme.colors.fg_muted) + .child(source.to_string()), + ) + .into_any_element() } + +#[cfg(test)] +mod tests; diff --git a/src/typography/math/tests.rs b/src/typography/math/tests.rs new file mode 100644 index 0000000..10b5ec4 --- /dev/null +++ b/src/typography/math/tests.rs @@ -0,0 +1,140 @@ +use super::*; + +fn formula(source: &str, display: bool) -> Formula { + Formula { + source: source.to_string().into(), + display, + rgba: 0x112233ff, + font: 1600, + scale: 200, + } +} + +#[test] +fn a_formula_typesets_to_outlined_svg_in_its_color() { + let (svg, shape) = svg(&formula(r"\frac{a}{b} + \sqrt{x}", false)).expect("typesets"); + assert!(svg.starts_with(" px(16.) && shape.height > px(16.), "{shape:?}"); +} + +#[test] +fn display_style_sets_limits_larger() { + let (_, text) = svg(&formula(r"\sum_{k=1}^{n} k", false)).expect("typesets"); + let (_, display) = svg(&formula(r"\sum_{k=1}^{n} k", true)).expect("typesets"); + assert!(display.height > text.height, "{display:?} over {text:?}"); +} + +#[test] +fn every_glyph_lies_inside_the_picture() { + for source in [ + r"\text{私密} x", + "x", + r"\int_0^1 f(x)\,dx", + r"\left(\frac{a}{b}\right)", + ] { + let (svg, shape) = svg(&formula(source, false)).expect("typesets"); + let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default()).expect("svg"); + let ink = tree.root().abs_stroke_bounding_box(); + let (w, h) = (shape.width.as_f32(), shape.height.as_f32()); + let near = |a: f32, b: f32| (a - b).abs() < 0.01; + assert!( + near(tree.size().width(), w) && near(tree.size().height(), h), + "{source}" + ); + assert!( + ink.left() >= -0.01 && ink.top() >= -0.01, + "{source}: ink {ink:?} starts before the picture" + ); + assert!( + ink.right() <= w + 0.01 && ink.bottom() <= h + 0.01, + "{source}: ink {ink:?} passes {w}×{h}" + ); + } +} + +#[test] +fn bad_tex_says_why_and_where() { + let Err(error) = Latex::parse("x", r"x^2^3") else { + panic!("a double superscript parses") + }; + assert_eq!(error.to_string(), "Double superscript at 3"); + assert!(Latex::parse("x", r"x^{2").is_err(), "an open brace"); +} + +#[test] +fn a_layout_panic_is_an_error_without_the_panic_text() { + let laid: Result<(), MathError> = guarded(|| panic!("private formula text")); + let error = laid.expect_err("the panic is an error"); + assert!(!error.to_string().contains("private"), "{error}"); +} + +/// Formulas drawn by id; the test changes them. +struct Shown(Vec<&'static str>); + +impl gpui::Render for Shown { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().children( + self.0 + .iter() + .enumerate() + .map(|(ix, source)| Latex::parse(ix, source).expect("parses")), + ) + } +} + +fn open<'a>( + sources: Vec<&'static str>, + cx: &'a mut gpui::TestAppContext, +) -> (gpui::Entity, &'a mut gpui::VisualTestContext) { + cx.update(crate::theme::Theme::init); + let (view, cx) = cx.add_window_view(|_, _| Shown(sources)); + settle(cx); + (view, cx) +} + +fn settle(cx: &mut gpui::VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +/// 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); + cx.has_asset::(&formula) + }) +} + +#[gpui::test] +fn a_formula_frees_its_picture_when_it_changes_or_leaves(cx: &mut gpui::TestAppContext) { + let (view, cx) = open(vec!["a^2"], cx); + assert!(cached("a^2", cx), "drawn once"); + view.update(cx, |view, cx| { + view.0 = vec!["b^2"]; + cx.notify(); + }); + settle(cx); + assert!(!cached("a^2", cx) && cached("b^2", cx), "the old one freed"); + view.update(cx, |view, cx| { + view.0.clear(); + cx.notify(); + }); + settle(cx); + assert!(!cached("b^2", cx), "gone with its element"); +} + +#[gpui::test] +fn a_shared_formula_draws_on_after_one_user_leaves(cx: &mut gpui::TestAppContext) { + let (view, cx) = open(vec!["c^2", "c^2"], cx); + view.update(cx, |view, cx| { + view.0.pop(); + cx.notify(); + }); + settle(cx); + assert!(cached("c^2", cx), "the one left loads it again"); +} diff --git a/src/typography/mod.rs b/src/typography/mod.rs index 10cd425..e8375fb 100644 --- a/src/typography/mod.rs +++ b/src/typography/mod.rs @@ -25,6 +25,7 @@ pub use formatted::{ }; pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo}; pub use link::{ExternalLink, Link}; +pub(crate) use math::failed; pub use math::{Latex, MathError}; pub use rolling::AnimatedNumber; pub use select::SelectableText;