Vectrum
Sign In

Render mermaid blocks in answers as diagrams

A ```mermaid fence in an answer is laid out by mermaid-rs-renderer in the
theme's colors and rasterized at the display's density, both off the main
thread, then cached. A header row keeps Copy and Show source; an unclosed
fence shows a skeleton while it streams, a broken one its source and the
error. Wide diagrams shrink to fit down to 60%, then scroll sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent e43b5e6commit76d9f81

Showing 7 changed files with 571 additions and 6 deletions.

+34−0Cargo.lockView File
@@ -3561,6 +3561,16 @@ dependencies = [
35613561 "wasm-bindgen",
35623562 ]
35633563
3564+ [[package]]
3565+ name = "json5"
3566+ version = "1.3.1"
3567+ source = "registry+https://github.com/rust-lang/crates.io-index"
3568+ checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
3569+ dependencies = [
3570+ "serde",
3571+ "ucd-trie",
3572+ ]
3573+
35643574 [[package]]
35653575 name = "khronos-egl"
35663576 version = "6.0.0"
@@ -3948,6 +3958,23 @@ dependencies = [
39483958 "autocfg",
39493959 ]
39503960
3961+ [[package]]
3962+ name = "mermaid-rs-renderer"
3963+ version = "0.3.1"
3964+ source = "registry+https://github.com/rust-lang/crates.io-index"
3965+ checksum = "6cb5b469222ce6b9896edf0a969cf68464f5a028ef394fd6643f00bd943df117"
3966+ dependencies = [
3967+ "anyhow",
3968+ "fontdb",
3969+ "json5",
3970+ "once_cell",
3971+ "regex",
3972+ "serde",
3973+ "serde_json",
3974+ "thiserror 2.0.21",
3975+ "ttf-parser",
3976+ ]
3977+
39513978 [[package]]
39523979 name = "metal"
39533980 version = "0.33.0"
@@ -7182,6 +7209,12 @@ version = "1.20.1"
71827209 source = "registry+https://github.com/rust-lang/crates.io-index"
71837210 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
71847211
7212+ [[package]]
7213+ name = "ucd-trie"
7214+ version = "0.1.7"
7215+ source = "registry+https://github.com/rust-lang/crates.io-index"
7216+ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
7217+
71857218 [[package]]
71867219 name = "uds_windows"
71877220 version = "1.2.1"
@@ -7448,6 +7481,7 @@ dependencies = [
74487481 "image",
74497482 "libc",
74507483 "openh264",
7484+ "mermaid-rs-renderer",
74517485 "serde",
74527486 "serde_json",
74537487 "smol",
+1−0Cargo.tomlView File
@@ -18,6 +18,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std"
1818 image = { version = "0.25", default-features = false, features = ["jpeg"] }
1919 alacritty_terminal = "0.26"
2020 openh264 = "0.9"
21+ mermaid-rs-renderer = { version = "0.3.1", default-features = false }
2122
2223 [profile.dev]
2324 debug = "line-tables-only"
+9−4src/ui/chat.rsView File
@@ -1975,7 +1975,7 @@ fn format_tokens(tokens: u64) -> String {
19751975
19761976 /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
19771977 /// the theme, which `metal` keeps in step with the theme.
1978- pub(super) fn markdown_style(p: &Palette) -> TextViewStyle {
1978+ pub(crate) fn markdown_style(p: &Palette) -> TextViewStyle {
19791979 let mut code_block = StyleRefinement::default()
19801980 .rounded(px(14.))
19811981 .bg(metal::vertical((p.well_top, p.well_bot)))
@@ -1994,9 +1994,14 @@ pub(super) fn markdown_style(p: &Palette) -> TextViewStyle {
19941994 })
19951995 }
19961996
1997- /// Markdown with a copy button on every code block.
1998- pub(super) fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palette) -> TextView {
1999- TextView::new(state)
1997+ /// Markdown with a copy button on every code block, and diagrams and UI
1998+ /// blocks drawn in place (see `ui::inline`).
1999+ pub(super) fn markdown(
2000+ state: &Entity<gpui_kit::component::text::TextViewState>,
2001+ icx: crate::ui::inline::InlineCx,
2002+ p: &Palette,
2003+ ) -> TextView {
2004+ crate::ui::inline::extend(TextView::new(state), icx)
20002005 .style(markdown_style(p))
20012006 .selectable(true)
20022007 .code_block_actions(|block, _, _| {
+4−2src/ui/chat/timeline.rsView File
@@ -433,7 +433,8 @@ pub(super) fn render_item(
433433 if meta.is_some() {
434434 bottom = px(16.);
435435 }
436- render_assistant(ix, &answer.text, &answer.view, meta, p)
436+ let icx = crate::ui::inline::InlineCx { session: session.entity_id(), ix, scroller: scroller.downgrade() };
437+ render_assistant(ix, &answer.text, &answer.view, icx, meta, p)
437438 }
438439 ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p),
439440 ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => {
@@ -596,6 +597,7 @@ fn render_assistant(
596597 ix: usize,
597598 text: &str,
598599 view: &Entity<gpui_kit::component::text::TextViewState>,
600+ icx: crate::ui::inline::InlineCx,
599601 meta: Option<SystemTime>,
600602 p: &Palette,
601603 ) -> AnyElement {
@@ -614,7 +616,7 @@ fn render_assistant(
614616 .child(if text.trim().is_empty() {
615617 div().text_color(p.ink3).child("(empty response)").into_any_element()
616618 } else {
617- markdown(view, p).into_any_element()
619+ markdown(view, icx, p).into_any_element()
618620 }),
619621 )
620622 .when_some(meta, |this, at| {
+199−0src/ui/inline/mermaid.rsaddedView File
@@ -0,0 +1,199 @@
1+ //! ` ```mermaid ` blocks as diagrams. `mermaid-rs-renderer` parses and lays
2+ //! them out in pure Rust and writes SVG, which GPUI's SVG renderer rasterizes
3+ //! at the display's scale, so diagrams stay crisp on HiDPI screens. Both steps
4+ //! run off the main thread, and the images are cached, because the first
5+ //! render loads the system font database and takes a moment.
6+
7+ use std::collections::{HashMap, VecDeque};
8+ use std::sync::Arc;
9+
10+ use gpui_kit::*;
11+ use mermaid_rs_renderer::{RenderOptions, Theme};
12+
13+ use super::{Body, Fence, InlineCx, hash, skeleton};
14+ use crate::ui::metal::{self, Palette};
15+
16+ /// How many rendered diagrams stay in memory. Each is a full-resolution
17+ /// bitmap, so the cache drops the oldest beyond this.
18+ const CACHE_SIZE: usize = 48;
19+
20+ /// Labels are measured by the layout with system fonts and drawn by GPUI's
21+ /// SVG renderer with system fonts too. Geist is only in the app's own text
22+ /// system, so a family both sides can find keeps labels inside their boxes.
23+ const FONT: &str = "Noto Sans, Liberation Sans, DejaVu Sans, Arial, sans-serif";
24+
25+ #[derive(Clone, Copy, PartialEq, Eq, Hash)]
26+ struct Key {
27+ code: u64,
28+ /// The palette, by address: palettes are statics, one per theme.
29+ palette: usize,
30+ scale: u32,
31+ }
32+
33+ enum Entry {
34+ Pending,
35+ Ready { image: Arc<RenderImage>, size: Size<Pixels> },
36+ Failed(String),
37+ }
38+
39+ #[derive(Default)]
40+ struct Cache {
41+ entries: HashMap<Key, Entry>,
42+ order: VecDeque<Key>,
43+ }
44+
45+ impl Global for Cache {}
46+
47+ pub(super) fn render(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body {
48+ let p = metal::palette(cx);
49+ let guess = px((fence.code.lines().count() as f32 * 28.).clamp(120., 360.));
50+ if !fence.closed {
51+ return Body::Ready(skeleton("Drawing diagram…", guess, p));
52+ }
53+ let scale = window.scale_factor();
54+ let key = Key {
55+ code: hash(&fence.code),
56+ palette: p as *const Palette as usize,
57+ scale: scale.to_bits(),
58+ };
59+ match cx.default_global::<Cache>().entries.get(&key) {
60+ Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, p)),
61+ Some(Entry::Failed(error)) => Body::Failed(error.clone()),
62+ Some(Entry::Pending) => Body::Ready(skeleton("Drawing diagram…", guess, p)),
63+ None => {
64+ start(key, fence.code.clone(), theme(p), scale, icx.clone(), cx);
65+ Body::Ready(skeleton("Drawing diagram…", guess, p))
66+ }
67+ }
68+ }
69+
70+ /// Lays out and rasterizes a diagram in the background, then stores it and
71+ /// redraws the windows.
72+ fn start(key: Key, code: String, theme: Theme, scale: f32, icx: InlineCx, cx: &mut App) {
73+ cx.default_global::<Cache>().entries.insert(key, Entry::Pending);
74+ let renderer = cx.svg_renderer();
75+ let job = cx.background_executor().spawn(async move {
76+ let svg = mermaid_rs_renderer::render_with_options(&code, RenderOptions { theme, ..RenderOptions::default() })
77+ .map_err(|err| format!("Mermaid: {}", first_line(&err.to_string())))?;
78+ super::rasterize(&renderer, &svg, scale).map_err(|err| format!("Couldn't draw the diagram: {err}"))
79+ });
80+ cx.spawn(async move |cx| {
81+ let result = job.await;
82+ cx.update(|cx| {
83+ let entry = match result {
84+ Ok((image, size)) => Entry::Ready { image, size },
85+ Err(error) => Entry::Failed(error),
86+ };
87+ let cache = cx.default_global::<Cache>();
88+ cache.entries.insert(key, entry);
89+ cache.order.push_back(key);
90+ let mut evicted = Vec::new();
91+ while cache.order.len() > CACHE_SIZE {
92+ if let Some(Entry::Ready { image, .. }) = cache.order.pop_front().and_then(|old| cache.entries.remove(&old)) {
93+ evicted.push(image);
94+ }
95+ }
96+ for image in evicted {
97+ cx.drop_image(image, None);
98+ }
99+ icx.remeasure(cx);
100+ cx.refresh_windows();
101+ });
102+ })
103+ .detach();
104+ }
105+
106+ fn first_line(text: &str) -> &str {
107+ text.lines().find(|line| !line.trim().is_empty()).unwrap_or(text).trim()
108+ }
109+
110+ /// The diagram on a recessed card, at its own size or shrunk to fit. Shrunk
111+ /// below `MIN_SCALE` its labels get too small to read, so a wider diagram
112+ /// scrolls sideways instead.
113+ fn diagram(id: ElementId, image: Arc<RenderImage>, size: Size<Pixels>, p: &Palette) -> AnyElement {
114+ const MIN_SCALE: f32 = 0.6;
115+ let ratio = f32::from(size.width) / f32::from(size.height).max(1.);
116+ div()
117+ .id(id)
118+ .w_full()
119+ .p(px(16.))
120+ .rounded(px(14.))
121+ .bg(card(p))
122+ .shadow(p.region_sh.clone())
123+ .overflow_x_scroll()
124+ .restrict_scroll_to_axis()
125+ .child(
126+ div()
127+ .mx_auto()
128+ .w(size.width)
129+ .max_w_full()
130+ .min_w(size.width * MIN_SCALE)
131+ .aspect_ratio(ratio)
132+ .child(img(image).size_full()),
133+ )
134+ .into_any_element()
135+ }
136+
137+ /// The flat color diagrams sit on: a recessed region of the sheet. Mermaid
138+ /// fills some shapes with its background color, so the card is opaque and
139+ /// can't be a gradient.
140+ fn card(p: &Palette) -> Hsla {
141+ over(p.region.0, p.s)
142+ }
143+
144+ /// `top` painted over `bottom`, as one opaque color.
145+ fn over(top: Hsla, bottom: Hsla) -> Hsla {
146+ let (top, bottom) = (Rgba::from(top), Rgba::from(bottom));
147+ let mix = |a: f32, b: f32| a * top.a + b * (1. - top.a);
148+ Rgba { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b), a: 1. }.into()
149+ }
150+
151+ /// Mermaid's theme in the app's colors: nodes as raised caps, ink for text
152+ /// and lines, the theme's tints for pie slices.
153+ fn theme(p: &Palette) -> Theme {
154+ let card = card(p);
155+ let css = |color: Hsla| css(color, card);
156+ let mut theme = if p.dark { Theme::dark() } else { Theme::modern() };
157+ theme.font_family = FONT.to_string();
158+ theme.font_size = 14.;
159+ theme.background = css(card);
160+ theme.primary_color = css(p.cap.0);
161+ theme.primary_text_color = css(p.ink);
162+ theme.primary_border_color = css(p.ink3);
163+ theme.line_color = css(p.ink2);
164+ theme.secondary_color = css(p.well_top);
165+ theme.tertiary_color = css(p.s_hi);
166+ theme.text_color = css(p.ink);
167+ theme.edge_label_background = css(card);
168+ theme.cluster_background = css(p.well_top);
169+ theme.cluster_border = css(p.rule);
170+ theme.sequence_actor_fill = css(p.cap.0);
171+ theme.sequence_actor_border = css(p.ink3);
172+ theme.sequence_actor_line = css(p.ink3);
173+ theme.sequence_note_fill = css(p.amber.opacity(0.18));
174+ theme.sequence_note_border = css(p.amber);
175+ theme.sequence_activation_fill = css(p.well_top);
176+ theme.sequence_activation_border = css(p.ink3);
177+ for (ix, color) in theme.pie_colors.iter_mut().enumerate() {
178+ let tint = p.tints[ix % p.tints.len()];
179+ *color = css(if ix < p.tints.len() { tint } else { tint.opacity(0.6) });
180+ }
181+ theme.pie_title_text_color = css(p.ink);
182+ theme.pie_section_text_color = css(p.ink);
183+ theme.pie_legend_text_color = css(p.ink);
184+ theme.pie_title_text_size = 18.;
185+ theme.pie_section_text_size = 14.;
186+ theme.pie_legend_text_size = 14.;
187+ theme.pie_stroke_color = css(card);
188+ theme.pie_outer_stroke_color = css(p.rule);
189+ theme.pie_opacity = 0.9;
190+ theme
191+ }
192+
193+ /// A color as opaque CSS hex, blended over `under` when it's translucent.
194+ /// Mermaid derives shades from some colors and only reads hex reliably.
195+ fn css(color: Hsla, under: Hsla) -> String {
196+ let color = Rgba::from(over(color, under));
197+ let byte = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8;
198+ format!("#{:02x}{:02x}{:02x}", byte(color.r), byte(color.g), byte(color.b))
199+ }
+323−0src/ui/inline/mod.rsaddedView File
@@ -0,0 +1,323 @@
1+ //! Rich blocks in agent answers. Agents write diagrams and small UIs as plain
2+ //! fenced code blocks (` ```mermaid `, ` ```openui `, ` ```svg `), so they
3+ //! read fine anywhere else; here the Markdown view swaps them for native
4+ //! renders. Each keeps a header row with Copy and Show source, so a render
5+ //! that goes wrong never hides what the agent wrote.
6+
7+ mod mermaid;
8+
9+ use std::collections::HashMap;
10+ use std::hash::Hasher as _;
11+
12+ use gpui_kit::component::{
13+ Icon, IconName, h_flex,
14+ message_scroller::MessageScrollerState,
15+ text::{MarkdownNode, MarkdownParseContext, TextView, markdown_ast},
16+ v_flex,
17+ };
18+ use gpui_kit::prelude::FluentBuilder as _;
19+ use gpui_kit::*;
20+
21+ use crate::ui::icons::Lucide;
22+ use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
23+
24+ /// The one custom node name every rich block parses into. A constant set of
25+ /// parsers and names keeps the Markdown view from reparsing every frame.
26+ const NODE: &str = "inline-ui";
27+
28+ /// Where a Markdown view sits, so a block's renderer knows which thread it
29+ /// belongs to and which transcript row to remeasure when its height changes.
30+ #[derive(Clone)]
31+ pub struct InlineCx {
32+ pub session: EntityId,
33+ pub ix: usize,
34+ pub scroller: WeakEntity<MessageScrollerState>,
35+ }
36+
37+ impl InlineCx {
38+ /// Tells the transcript a block changed height, so the virtual list
39+ /// doesn't jump on the next frame.
40+ fn remeasure(&self, cx: &mut App) {
41+ let ix = self.ix;
42+ if let Some(scroller) = self.scroller.upgrade() {
43+ scroller.update(cx, |scroller, cx| {
44+ scroller.remeasure_items(ix..ix + 1, cx);
45+ });
46+ }
47+ }
48+ }
49+
50+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
51+ enum Lang {
52+ Mermaid,
53+ }
54+
55+ impl Lang {
56+ fn parse(lang: &str) -> Option<Self> {
57+ match lang.trim().to_ascii_lowercase().as_str() {
58+ "mermaid" => Some(Self::Mermaid),
59+ _ => None,
60+ }
61+ }
62+
63+ fn label(self) -> &'static str {
64+ match self {
65+ Self::Mermaid => "Diagram",
66+ }
67+ }
68+
69+ fn fence(self) -> &'static str {
70+ match self {
71+ Self::Mermaid => "mermaid",
72+ }
73+ }
74+ }
75+
76+ /// A fenced block as the parser hands it to the renderer. Parsing runs on a
77+ /// background thread, so this holds only plain data.
78+ struct Fence {
79+ lang: Lang,
80+ code: String,
81+ /// Whether the closing fence has arrived. Streaming answers show
82+ /// unfinished blocks as skeletons, and never as errors.
83+ closed: bool,
84+ }
85+
86+ /// Adds the rich block hooks to an answer's Markdown view.
87+ pub fn extend(view: TextView, icx: InlineCx) -> TextView {
88+ view.markdown_block_parser(parse).markdown_block_renderer(NODE, move |node, window, cx| render(node, &icx, window, cx))
89+ }
90+
91+ fn parse(node: &markdown_ast::Node, cx: &MarkdownParseContext) -> Option<MarkdownNode> {
92+ let markdown_ast::Node::Code(code) = node else {
93+ return None;
94+ };
95+ let lang = Lang::parse(code.lang.as_deref()?)?;
96+ let source = cx.node_source(node)?;
97+ let fence = Fence {
98+ lang,
99+ code: code.value.clone(),
100+ closed: is_closed(source),
101+ };
102+ Some(MarkdownNode::new(NODE, fence).text(source.to_string()).markdown(source.to_string()))
103+ }
104+
105+ /// Whether a fence's source ends with a closing fence. An unclosed fence runs
106+ /// to the end of the document, which is what a block mid-stream looks like.
107+ fn is_closed(source: &str) -> bool {
108+ let mut lines = source.trim_end().lines();
109+ let Some(open) = lines.next() else { return false };
110+ let open = open.trim_start();
111+ let Some(marker) = open.chars().next().filter(|c| *c == '`' || *c == '~') else {
112+ return false;
113+ };
114+ let width = open.chars().take_while(|c| *c == marker).count();
115+ lines.next_back().is_some_and(|close| {
116+ let close = close.trim();
117+ close.len() >= width && close.chars().all(|c| c == marker)
118+ })
119+ }
120+
121+ fn hash(value: impl std::hash::Hash) -> u64 {
122+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
123+ value.hash(&mut hasher);
124+ hasher.finish()
125+ }
126+
127+ /// What blocks remember across frames. Rows of the virtual list drop their
128+ /// element state when they scroll away, so this lives in a global.
129+ #[derive(Default)]
130+ struct Store {
131+ blocks: HashMap<(EntityId, u64), BlockState>,
132+ }
133+
134+ impl Global for Store {}
135+
136+ #[derive(Default)]
137+ struct BlockState {
138+ show_source: bool,
139+ }
140+
141+ /// A rendered block body, or why it couldn't be drawn.
142+ enum Body {
143+ Ready(AnyElement),
144+ Failed(String),
145+ }
146+
147+ fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App) -> AnyElement {
148+ let Some(fence) = node.data::<Fence>() else {
149+ return div().into_any_element();
150+ };
151+ let p = metal::palette(cx);
152+ let key = (icx.session, hash((node.source_range().map(|range| range.start), &fence.code)));
153+ let id = ElementId::NamedInteger("inline-ui".into(), key.1);
154+ let show_source = cx.try_global::<Store>().and_then(|store| store.blocks.get(&key)).is_some_and(|block| block.show_source);
155+ let body = if show_source {
156+ Body::Ready(source_view(id.clone(), fence, p))
157+ } else {
158+ match fence.lang {
159+ Lang::Mermaid => mermaid::render(id.clone(), fence, icx, window, cx),
160+ }
161+ };
162+ let error = match &body {
163+ Body::Failed(error) => Some(error.clone()),
164+ Body::Ready(_) => None,
165+ };
166+ let body = match body {
167+ Body::Ready(element) => element,
168+ Body::Failed(_) => source_view(id.clone(), fence, p),
169+ };
170+ v_flex()
171+ .w_full()
172+ .gap(px(6.))
173+ .child(header(id, fence, key, show_source, error.is_some(), icx, p))
174+ .child(body)
175+ .when_some(error, |this, error| {
176+ this.child(div().px(px(4.)).text_size(px(12.5)).text_color(p.red).truncate().child(error))
177+ })
178+ .into_any_element()
179+ }
180+
181+ /// The row over a block: its kind, Show source and Copy.
182+ fn header(
183+ id: ElementId,
184+ fence: &Fence,
185+ key: (EntityId, u64),
186+ show_source: bool,
187+ failed: bool,
188+ icx: &InlineCx,
189+ p: &Palette,
190+ ) -> impl IntoElement {
191+ let code = fence.code.clone();
192+ let icx = icx.clone();
193+ h_flex()
194+ .w_full()
195+ .h(px(22.))
196+ .px(px(4.))
197+ .gap(px(4.))
198+ .child(metal::engraved(fence.lang.label(), p))
199+ .child(div().flex_1())
200+ .when(!failed, |this| {
201+ this.child(
202+ MetalButton::new(ElementId::Name(format!("{id}-source").into()))
203+ .ghost()
204+ .mini()
205+ .icon(if show_source { Icon::new(IconName::Eye) } else { Icon::new(Lucide::Code) })
206+ .tooltip(if show_source { "Show rendered" } else { "Show source" })
207+ .on_click(move |_, window, cx| {
208+ let block = cx.default_global::<Store>().blocks.entry(key).or_default();
209+ block.show_source = !block.show_source;
210+ icx.remeasure(cx);
211+ window.refresh();
212+ }),
213+ )
214+ })
215+ .child(
216+ MetalButton::new(ElementId::Name(format!("{id}-copy").into()))
217+ .ghost()
218+ .mini()
219+ .icon(IconName::Copy)
220+ .tooltip("Copy source")
221+ .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.clone()))),
222+ )
223+ }
224+
225+ /// The block as the plain code it is, in the same well code blocks use.
226+ fn source_view(id: ElementId, fence: &Fence, p: &Palette) -> AnyElement {
227+ let markdown = format!("```{}\n{}\n```", fence.lang.fence(), fence.code.trim_end_matches('\n'));
228+ TextView::markdown(ElementId::Name(format!("{id}-code").into()), markdown)
229+ .style(crate::ui::chat::markdown_style(p))
230+ .selectable(true)
231+ .into_any_element()
232+ }
233+
234+ /// Rasterizes SVG at exactly the display's density. GPUI's own
235+ /// `render_single_frame` draws at twice that for smoothing, which quadruples
236+ /// the memory a large diagram takes for no visible gain at this size.
237+ fn rasterize(renderer: &SvgRenderer, svg: &str, scale: f32) -> anyhow::Result<(std::sync::Arc<RenderImage>, Size<Pixels>)> {
238+ let (width, height) = svg_size(svg).ok_or_else(|| anyhow::anyhow!("the SVG has no size"))?;
239+ anyhow::ensure!(width * height * scale * scale <= MAX_PIXELS, "the image is too large");
240+ let parsed = renderer.parse_svg(svg.as_bytes())?;
241+ let pixels = size(DevicePixels((width * scale).ceil() as i32), DevicePixels((height * scale).ceil() as i32));
242+ let image = renderer.render_parsed(&parsed, SvgSize::ExactSize(pixels))?;
243+ Ok((image, size(px(width), px(height))))
244+ }
245+
246+ /// The largest bitmap a block may rasterize to, in device pixels.
247+ const MAX_PIXELS: f32 = 6000. * 6000.;
248+
249+ /// The width and height an SVG asks for: its `width` and `height`
250+ /// attributes, else its `viewBox`.
251+ fn svg_size(svg: &str) -> Option<(f32, f32)> {
252+ let start = svg.find("<svg")?;
253+ let tag = &svg[start..start + svg[start..].find('>')?];
254+ let attr = |name: &str| -> Option<&str> {
255+ let mut rest = tag;
256+ loop {
257+ let at = rest.find(name)?;
258+ let before = rest[..at].chars().next_back();
259+ rest = &rest[at + name.len()..];
260+ if before.is_some_and(char::is_whitespace) && rest.trim_start().starts_with('=') {
261+ let value = rest.trim_start()[1..].trim_start();
262+ let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?;
263+ return value[1..].split(quote).next();
264+ }
265+ }
266+ };
267+ let length = |value: &str| -> Option<f32> {
268+ let value = value.trim().trim_end_matches("px");
269+ value.parse::<f32>().ok().filter(|value| value.is_finite() && *value > 0.)
270+ };
271+ let view_box = attr("viewBox").and_then(|value| {
272+ let numbers: Vec<f32> = value.split([' ', ',']).filter(|part| !part.is_empty()).filter_map(|part| part.parse().ok()).collect();
273+ (numbers.len() == 4 && numbers[2] > 0. && numbers[3] > 0.).then(|| (numbers[2], numbers[3]))
274+ });
275+ let width = attr("width").and_then(length);
276+ let height = attr("height").and_then(length);
277+ match (width, height, view_box) {
278+ (Some(width), Some(height), _) => Some((width, height)),
279+ (Some(width), None, Some((w, h))) => Some((width, width * h / w)),
280+ (None, Some(height), Some((w, h))) => Some((height * w / h, height)),
281+ (_, _, view_box) => view_box,
282+ }
283+ }
284+
285+ /// A placeholder while a block streams in or renders.
286+ fn skeleton(label: &'static str, height: Pixels, p: &Palette) -> AnyElement {
287+ div()
288+ .w_full()
289+ .h(height)
290+ .flex()
291+ .items_center()
292+ .justify_center()
293+ .rounded(px(14.))
294+ .region(p)
295+ .text_size(px(12.5))
296+ .text_color(p.ink3)
297+ .child(label)
298+ .into_any_element()
299+ }
300+
301+ #[cfg(test)]
302+ mod tests {
303+ use super::{is_closed, svg_size};
304+
305+ #[::core::prelude::v1::test]
306+ fn svg_sizes() {
307+ assert_eq!(svg_size(r#"<svg xmlns="x" width="120" height="80">"#), Some((120., 80.)));
308+ assert_eq!(svg_size(r#"<svg viewBox="0 0 300 150" width="600px">"#), Some((600., 300.)));
309+ assert_eq!(svg_size(r#"<?xml?><svg stroke-width="2" viewBox='0,0,40,20'>"#), Some((40., 20.)));
310+ assert_eq!(svg_size(r#"<svg width="100%" viewBox="0 0 40 20">"#), Some((40., 20.)));
311+ assert_eq!(svg_size("<svg>"), None);
312+ }
313+
314+ #[::core::prelude::v1::test]
315+ fn closed_fences() {
316+ assert!(is_closed("```mermaid\ngraph TD\n```"));
317+ assert!(is_closed("````mermaid\ngraph TD\n````\n"));
318+ assert!(is_closed("~~~svg\n<svg/>\n~~~"));
319+ assert!(!is_closed("```mermaid\ngraph TD"));
320+ assert!(!is_closed("```mermaid"));
321+ assert!(!is_closed("````mermaid\ngraph TD\n```"));
322+ }
323+ }
+1−0src/ui/mod.rsView File
@@ -5,6 +5,7 @@ pub mod composer;
55 pub mod device;
66 pub mod folder_browser;
77 pub mod icons;
8+ pub mod inline;
89 pub mod markdown_input;
910 pub mod metal;
1011 pub mod pickers;