//! ` ```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<RenderImage>, size: Size<Pixels> },
Failed(String),
}
#[derive(Default)]
struct Cache {
entries: HashMap<Key, Entry>,
order: VecDeque<Key>,
}
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::<Cache>().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::<Cache>().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<String, String> + Send + 'static) {
cx.default_global::<Cache>().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>();
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<RenderImage>, size: Size<Pixels>, 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))
}