use std::{
collections::HashMap,
hash::{DefaultHasher, Hash, Hasher},
rc::Rc,
};
use canvas_core::Canvas;
use gpui::{
AnyElement, App, ElementId, Entity, Global, MouseButton, MouseDownEvent, MouseMoveEvent,
Pixels, Point, SharedString, Window, div, prelude::*, px,
};
use theme::{TextStyle, Theme};
use ui::{
icons::glyph,
tooltip::Tooltip,
widgets::{ButtonStyle, Buttons as _},
};
use crate::render::{copy_button, fence_band, fence_panel};
pub const LANGUAGE: &str = "mermaid";
const CACHED: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Zoom {
pub min: f32,
pub max: f32,
pub step: f32,
}
impl Default for Zoom {
fn default() -> Self {
Self {
min: 0.5,
max: 2.0,
step: 1.25,
}
}
}
impl Zoom {
pub(crate) fn of(cx: &App) -> Self {
cx.try_global::<Installed>()
.map_or_else(Self::default, |installed| installed.0)
}
fn at(&self, steps: i32) -> f32 {
self.step.powi(steps).clamp(self.min, self.max)
}
}
struct Installed(Zoom);
impl Global for Installed {}
pub(crate) fn set_zoom(cx: &mut App, zoom: Zoom) {
cx.set_global(Installed(zoom));
}
const MAX_HEIGHT: f32 = 480.0;
#[derive(Default)]
struct Cache(HashMap<u64, Option<Rc<Canvas>>>);
impl Global for Cache {}
#[derive(Clone, Copy, Default)]
struct View {
zoom: i32,
pan: Point<Pixels>,
held: Option<(Point<Pixels>, Point<Pixels>)>,
}
fn hash(code: &str, size: f32) -> u64 {
let mut hasher = DefaultHasher::new();
(code, size.to_bits()).hash(&mut hasher);
hasher.finish()
}
fn laid_out(code: &str, size: f32, cx: &mut App) -> Option<Rc<Canvas>> {
let key = hash(code, size);
let cache = &mut cx.default_global::<Cache>().0;
if let Some(canvas) = cache.get(&key) {
return canvas.clone();
}
if cache.len() >= CACHED {
cache.clear();
}
let canvas = canvas_core::mermaid::import(code, size).map(Rc::new);
cache.insert(key, canvas.clone());
canvas
}
pub fn render(
code: &str,
height: Option<u32>,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let size = TextStyle::Callout.painted();
let Some(base) = laid_out(code, size, cx) else {
return crate::charts::render(LANGUAGE, code, chart::mermaid::import, height, window, cx);
};
let (_, natural) = canvas_core::diagram::size_of(&base)?;
let key = hash(code, size);
let id = format!("mermaid-{key:x}");
let state = window.use_keyed_state(
ElementId::from(SharedString::from(id.clone())),
cx,
|_, _| View::default(),
);
let view = *state.read(cx);
let zoom = Zoom::of(cx);
let scale = zoom.at(view.zoom);
let canvas = match scale == 1.0 {
true => base,
false => laid_out(code, size * scale, cx)?,
};
let theme = Theme::of(cx).clone();
let (press, follow, release, release_out) =
(state.clone(), state.clone(), state.clone(), state.clone());
let viewport = div()
.id(SharedString::from(format!("{id}-view")))
.debug_selector(|| LANGUAGE.into())
.relative()
.w_full()
.map(|el| match height {
Some(_) => el.flex_1().min_h_0(),
None => el.h(px(natural.min(MAX_HEIGHT))),
})
.overflow_hidden()
.map(|el| match view.held {
Some(_) => el.cursor_grabbing(),
None => el.cursor_grab(),
})
.on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
press.update(cx, |view, cx| {
view.held = Some((event.position, view.pan));
cx.notify();
});
})
.on_mouse_move(move |event: &MouseMoveEvent, _, cx| {
let Some((from, pan)) = follow.read(cx).held else {
return;
};
if !event.dragging() {
return;
}
follow.update(cx, |view, cx| {
view.pan = pan + (event.position - from);
cx.notify();
});
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| end_pan(&release, cx))
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| end_pan(&release_out, cx))
.child(
div()
.absolute()
.left(view.pan.x)
.top(view.pan.y)
.child(canvas_core::diagram(&canvas, cx)),
)
.child(controls(&id, &state, view, scale, zoom, &theme));
Some(
fence_panel(&theme)
.when_some(height, |el, height| {
el.h(px(height as f32)).flex().flex_col()
})
.child(
fence_band(&theme)
.flex_none()
.text_color(theme.text_muted)
.child(LANGUAGE),
)
.child(viewport)
.child(copy_button(code, key as usize, &theme, window, cx))
.into_any_element(),
)
}
fn end_pan(state: &Entity<View>, cx: &mut App) {
state.update(cx, |view, cx| {
if view.held.take().is_some() {
cx.notify();
}
});
}
fn controls(
id: &str,
state: &Entity<View>,
view: View,
scale: f32,
zoom: Zoom,
theme: &Theme,
) -> AnyElement {
let button =
|name: &str, icon: &'static [u8], tip: &'static str, enabled: bool, act: fn(&mut View)| {
let state = state.clone();
theme
.icon_button(icon, ButtonStyle::Ghost, None)
.id(SharedString::from(format!("{id}-{name}")))
.when(!enabled, |el| el.opacity(0.4))
.tooltip(move |window, cx| Tooltip::text(tip, window, cx))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(move |_, _, cx| {
cx.stop_propagation();
if enabled {
state.update(cx, |view, cx| {
act(view);
cx.notify();
});
}
})
};
theme
.control_group()
.absolute()
.right(px(6.0))
.bottom(px(6.0))
.cursor_default()
.child(button(
"zoom-out",
glyph::ZoomOut,
"Zoom out",
scale > zoom.min,
|view| view.zoom -= 1,
))
.child(button(
"zoom-in",
glyph::ZoomIn,
"Zoom in",
scale < zoom.max,
|view| view.zoom += 1,
))
.child(button(
"reset",
glyph::RotateCcw,
"Reset view",
view.zoom != 0 || view.pan != Point::default(),
|view| *view = View::default(),
))
.into_any_element()
}