Vectrum
Sign In
229 lines · 9.0 KB · Rust
//! ` ```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))
}