//! ` ```mermaid ` blocks as diagrams. `mermaid-rs-renderer` parses and lays //! them out in pure Rust and writes SVG, which GPUI's SVG renderer rasterizes //! at the display's scale, so diagrams stay crisp on HiDPI screens. Both steps //! run off the main thread, and the images are cached, because the first //! render loads the system font database and takes a moment. use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use gpui_kit::*; use mermaid_rs_renderer::{RenderOptions, Theme}; use super::{Body, Fence, InlineCx, hash, skeleton}; use crate::ui::metal::{self, Palette}; /// How many rendered diagrams stay in memory. Each is a full-resolution /// bitmap, so the cache drops the oldest beyond this. const CACHE_SIZE: usize = 48; /// Labels are measured by the layout with system fonts and drawn by GPUI's /// SVG renderer with system fonts too. Geist is only in the app's own text /// system, so a family both sides can find keeps labels inside their boxes. const FONT: &str = "Noto Sans, Liberation Sans, DejaVu Sans, Arial, sans-serif"; #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct Key { code: u64, /// The palette, by address: palettes are statics, one per theme. palette: usize, scale: u32, } enum Entry { Pending, Ready { image: Arc, size: Size }, Failed(String), } #[derive(Default)] struct Cache { entries: HashMap, order: VecDeque, } impl Global for Cache {} pub(super) fn render(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body { let p = metal::palette(cx); let guess = px((fence.code.lines().count() as f32 * 28.).clamp(120., 360.)); if !fence.closed { return Body::Ready(skeleton("Drawing diagram…", guess, p)); } let scale = window.scale_factor(); let key = Key { code: hash(&fence.code), palette: p as *const Palette as usize, scale: scale.to_bits(), }; match cx.default_global::().entries.get(&key) { Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, card(p), p)), Some(Entry::Failed(error)) => Body::Failed(error.clone()), Some(Entry::Pending) => Body::Ready(skeleton("Drawing diagram…", guess, p)), None => { let (code, theme) = (fence.code.clone(), theme(p)); start(key, scale, icx.clone(), cx, move || { mermaid_rs_renderer::render_with_options(&code, RenderOptions { theme, ..RenderOptions::default() }) .map_err(|err| format!("Mermaid: {}", first_line(&err.to_string()))) }); Body::Ready(skeleton("Drawing diagram…", guess, p)) } } } /// ` ```svg ` blocks: the agent's own SVG, drawn the same way. Its colors /// are its own, so it sits on a light card like a page, whatever the theme. pub(super) fn render_svg(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body { const MAX_SOURCE: usize = 1 << 20; let p = metal::palette(cx); if fence.code.len() > MAX_SOURCE { return Body::Failed("The SVG is larger than 1 MB".into()); } if !fence.closed { return Body::Ready(skeleton("Drawing image…", px(160.), p)); } let scale = window.scale_factor(); // Palettes are statics, so 0 never collides with one. let key = Key { code: hash(&fence.code), palette: 0, scale: scale.to_bits() }; let paper = if p.dark { rgb(0xe9e6df).into() } else { rgb(0xfdfcfa).into() }; match cx.default_global::().entries.get(&key) { Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, paper, p)), Some(Entry::Failed(error)) => Body::Failed(error.clone()), Some(Entry::Pending) => Body::Ready(skeleton("Drawing image…", px(160.), p)), None => { let code = fence.code.clone(); start(key, scale, icx.clone(), cx, move || Ok(code)); Body::Ready(skeleton("Drawing image…", px(160.), p)) } } } /// Makes SVG with `svg` and rasterizes it in the background, then stores /// the image and redraws the windows. fn start(key: Key, scale: f32, icx: InlineCx, cx: &mut App, svg: impl FnOnce() -> Result + Send + 'static) { cx.default_global::().entries.insert(key, Entry::Pending); let renderer = cx.svg_renderer(); let job = cx.background_executor().spawn(async move { let svg = svg()?; super::rasterize(&renderer, &svg, scale).map_err(|err| format!("Couldn't draw it: {err}")) }); cx.spawn(async move |cx| { let result = job.await; cx.update(|cx| { let entry = match result { Ok((image, size)) => Entry::Ready { image, size }, Err(error) => Entry::Failed(error), }; let cache = cx.default_global::(); cache.entries.insert(key, entry); cache.order.push_back(key); let mut evicted = Vec::new(); while cache.order.len() > CACHE_SIZE { if let Some(Entry::Ready { image, .. }) = cache.order.pop_front().and_then(|old| cache.entries.remove(&old)) { evicted.push(image); } } for image in evicted { cx.drop_image(image, None); } icx.remeasure(cx); cx.refresh_windows(); }); }) .detach(); } fn first_line(text: &str) -> &str { text.lines().find(|line| !line.trim().is_empty()).unwrap_or(text).trim() } /// The diagram on a recessed card, at its own size or shrunk to fit. Shrunk /// below `MIN_SCALE` its labels get too small to read, so a wider diagram /// scrolls sideways instead. fn diagram(id: ElementId, image: Arc, size: Size, background: Hsla, p: &Palette) -> AnyElement { const MIN_SCALE: f32 = 0.6; let ratio = f32::from(size.width) / f32::from(size.height).max(1.); div() .id(id) .w_full() .p(px(16.)) .rounded(px(14.)) .bg(background) .shadow(p.region_sh.clone()) .overflow_x_scroll() .restrict_scroll_to_axis() .child( div() .mx_auto() .w(size.width) .max_w_full() .min_w(size.width * MIN_SCALE) .aspect_ratio(ratio) .child(img(image).size_full()), ) .into_any_element() } /// The flat color diagrams sit on: a recessed region of the sheet. Mermaid /// fills some shapes with its background color, so the card is opaque and /// can't be a gradient. fn card(p: &Palette) -> Hsla { over(p.region.0, p.s) } /// `top` painted over `bottom`, as one opaque color. fn over(top: Hsla, bottom: Hsla) -> Hsla { let (top, bottom) = (Rgba::from(top), Rgba::from(bottom)); let mix = |a: f32, b: f32| a * top.a + b * (1. - top.a); Rgba { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b), a: 1. }.into() } /// Mermaid's theme in the app's colors: nodes as raised caps, ink for text /// and lines, the theme's tints for pie slices. fn theme(p: &Palette) -> Theme { let card = card(p); let css = |color: Hsla| css(color, card); let mut theme = if p.dark { Theme::dark() } else { Theme::modern() }; theme.font_family = FONT.to_string(); theme.font_size = 14.; theme.background = css(card); theme.primary_color = css(p.cap.0); theme.primary_text_color = css(p.ink); theme.primary_border_color = css(p.ink3); theme.line_color = css(p.ink2); theme.secondary_color = css(p.well_top); theme.tertiary_color = css(p.s_hi); theme.text_color = css(p.ink); theme.edge_label_background = css(card); theme.cluster_background = css(p.well_top); theme.cluster_border = css(p.rule); theme.sequence_actor_fill = css(p.cap.0); theme.sequence_actor_border = css(p.ink3); theme.sequence_actor_line = css(p.ink3); theme.sequence_note_fill = css(p.amber.opacity(0.18)); theme.sequence_note_border = css(p.amber); theme.sequence_activation_fill = css(p.well_top); theme.sequence_activation_border = css(p.ink3); for (ix, color) in theme.pie_colors.iter_mut().enumerate() { let tint = p.tints[ix % p.tints.len()]; *color = css(if ix < p.tints.len() { tint } else { tint.opacity(0.6) }); } theme.pie_title_text_color = css(p.ink); theme.pie_section_text_color = css(p.ink); theme.pie_legend_text_color = css(p.ink); theme.pie_title_text_size = 18.; theme.pie_section_text_size = 14.; theme.pie_legend_text_size = 14.; theme.pie_stroke_color = css(card); theme.pie_outer_stroke_color = css(p.rule); theme.pie_opacity = 0.9; theme } /// A color as opaque CSS hex, blended over `under` when it's translucent. /// Mermaid derives shades from some colors and only reads hex reliably. fn css(color: Hsla, under: Hsla) -> String { let color = Rgba::from(over(color, under)); let byte = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8; format!("#{:02x}{:02x}{:02x}", byte(color.r), byte(color.g), byte(color.b)) }