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>
Showing 7 changed files with 571 additions and 6 deletions.
| @@ -3561,6 +3561,16 @@ dependencies = [ | ||
| 3561 | 3561 | "wasm-bindgen", |
| 3562 | 3562 | ] |
| 3563 | 3563 | |
| 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 | + | |
| 3564 | 3574 | [[package]] |
| 3565 | 3575 | name = "khronos-egl" |
| 3566 | 3576 | version = "6.0.0" |
| @@ -3948,6 +3958,23 @@ dependencies = [ | ||
| 3948 | 3958 | "autocfg", |
| 3949 | 3959 | ] |
| 3950 | 3960 | |
| 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 | + | |
| 3951 | 3978 | [[package]] |
| 3952 | 3979 | name = "metal" |
| 3953 | 3980 | version = "0.33.0" |
| @@ -7182,6 +7209,12 @@ version = "1.20.1" | ||
| 7182 | 7209 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 7183 | 7210 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" |
| 7184 | 7211 | |
| 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 | + | |
| 7185 | 7218 | [[package]] |
| 7186 | 7219 | name = "uds_windows" |
| 7187 | 7220 | version = "1.2.1" |
| @@ -7448,6 +7481,7 @@ dependencies = [ | ||
| 7448 | 7481 | "image", |
| 7449 | 7482 | "libc", |
| 7450 | 7483 | "openh264", |
| 7484 | + "mermaid-rs-renderer", | |
| 7451 | 7485 | "serde", |
| 7452 | 7486 | "serde_json", |
| 7453 | 7487 | "smol", |
| @@ -18,6 +18,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" | ||
| 18 | 18 | image = { version = "0.25", default-features = false, features = ["jpeg"] } |
| 19 | 19 | alacritty_terminal = "0.26" |
| 20 | 20 | openh264 = "0.9" |
| 21 | + mermaid-rs-renderer = { version = "0.3.1", default-features = false } | |
| 21 | 22 | |
| 22 | 23 | [profile.dev] |
| 23 | 24 | debug = "line-tables-only" |
| @@ -1975,7 +1975,7 @@ fn format_tokens(tokens: u64) -> String { | ||
| 1975 | 1975 | |
| 1976 | 1976 | /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from |
| 1977 | 1977 | /// 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 { | |
| 1979 | 1979 | let mut code_block = StyleRefinement::default() |
| 1980 | 1980 | .rounded(px(14.)) |
| 1981 | 1981 | .bg(metal::vertical((p.well_top, p.well_bot))) |
| @@ -1994,9 +1994,14 @@ pub(super) fn markdown_style(p: &Palette) -> TextViewStyle { | ||
| 1994 | 1994 | }) |
| 1995 | 1995 | } |
| 1996 | 1996 | |
| 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) | |
| 2000 | 2005 | .style(markdown_style(p)) |
| 2001 | 2006 | .selectable(true) |
| 2002 | 2007 | .code_block_actions(|block, _, _| { |
| @@ -433,7 +433,8 @@ pub(super) fn render_item( | ||
| 433 | 433 | if meta.is_some() { |
| 434 | 434 | bottom = px(16.); |
| 435 | 435 | } |
| 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) | |
| 437 | 438 | } |
| 438 | 439 | ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p), |
| 439 | 440 | ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => { |
| @@ -596,6 +597,7 @@ fn render_assistant( | ||
| 596 | 597 | ix: usize, |
| 597 | 598 | text: &str, |
| 598 | 599 | view: &Entity<gpui_kit::component::text::TextViewState>, |
| 600 | + icx: crate::ui::inline::InlineCx, | |
| 599 | 601 | meta: Option<SystemTime>, |
| 600 | 602 | p: &Palette, |
| 601 | 603 | ) -> AnyElement { |
| @@ -614,7 +616,7 @@ fn render_assistant( | ||
| 614 | 616 | .child(if text.trim().is_empty() { |
| 615 | 617 | div().text_color(p.ink3).child("(empty response)").into_any_element() |
| 616 | 618 | } else { |
| 617 | - markdown(view, p).into_any_element() | |
| 619 | + markdown(view, icx, p).into_any_element() | |
| 618 | 620 | }), |
| 619 | 621 | ) |
| 620 | 622 | .when_some(meta, |this, at| { |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -5,6 +5,7 @@ pub mod composer; | ||
| 5 | 5 | pub mod device; |
| 6 | 6 | pub mod folder_browser; |
| 7 | 7 | pub mod icons; |
| 8 | + pub mod inline; | |
| 8 | 9 | pub mod markdown_input; |
| 9 | 10 | pub mod metal; |
| 10 | 11 | pub mod pickers; |