GPUI
Paint diagrams in GPUI windows, measure their text with GPUI's text system, and find the part under the pointer.
The limu-gpui crate paints limu’s scenes in GPUI windows, the UI framework Zed is built with. A scene is painted during an element’s paint phase, on the GPU along with the rest of the window. Text is measured and set by GPUI’s own text system, so a diagram uses your app’s fonts. limu-scene finds the item under the pointer, so a diagram in a window can answer clicks.
limu-rs is young, and its API will change in any 0.x release.
Installing
limu-gpui builds on gpui-pre 0.3.11, a weekly snapshot of Zed’s GPUI published on crates.io, and your app needs the same version. Name it gpui in Cargo.toml, so that the paths its macros write resolve. On macOS, turn on gpui-pre-platform’s font-kit feature, or no text is drawn. To use a GPUI of your own, such as one with patches, put it in gpui-pre’s place for the whole build with [patch.crates-io].
1[dependencies]2gpui = { package = "gpui-pre", version = "0.3.11" }3gpui_platform = { package = "gpui-pre-platform", version = "0.3.11", features = ["font-kit"] }4limu = "0.2"5limu-gpui = "0.2"The examples below use limu::scene, which is the limu-scene crate as limu re-exports it.
Showing a diagram
limu_gpui::paint paints a scene with its top left corner at a point of the window, each of its units a number of pixels. Call it in the paint closure of a canvas element, or in the paint phase of an element of your own. Lay out the diagram with the window’s text system, as explained under Fonts.
1use std::sync::Arc;23use gpui::{4 App, Bounds, Context, Pixels, Window, WindowBounds, WindowOptions, canvas, div, prelude::*,5 px, rgba, size,6};7use limu::scene::Scene;8use limu::scene::style::{Color, Look, Theme};9use limu_gpui::{Fonts, Options, TextMeasurer, paint};1011const SOURCE: &str = "flowchart LR12 text[Mermaid text] --> layout[Layout] --> scene[Scene]";1314struct Diagram {15 scene: Arc<Scene>,16 look: Look,17}1819impl Diagram {20 fn new(window: &Window) -> Diagram {21 // Text is measured by the window's text system, in the fonts it is drawn in.22 let measurer = TextMeasurer::new(window, Fonts::default());23 let layout = limu::layout::Options {24 measurer: &measurer,25 ..Default::default()26 };27 let scene = limu::layout(SOURCE, &layout).expect("a diagram");28 Diagram {29 scene: Arc::new(scene),30 look: Look::new(Theme::KOHU),31 }32 }33}3435impl Render for Diagram {36 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {37 let scene = self.scene.clone();38 let look = self.look.clone();39 div().size_full().p(px(16.0)).bg(color(look.theme.bg)).child(40 canvas(41 |_, _, _| {},42 move |bounds: Bounds<Pixels>, _, window, cx| {43 let o = Options {44 look,45 ..Options::default()46 };47 paint(&scene, &o, bounds.origin, 1.0, window, cx);48 },49 )50 .size_full(),51 )52 }53}5455/// A color of limu's as GPUI's.56fn color(c: Color) -> gpui::Rgba {57 rgba(u32::from_be_bytes([c.r, c.g, c.b, c.a]))58}5960fn main() {61 gpui_platform::application().run(|cx: &mut App| {62 let bounds = Bounds::centered(None, size(px(640.0), px(360.0)), cx);63 cx.open_window(64 WindowOptions {65 window_bounds: Some(WindowBounds::Windowed(bounds)),66 ..Default::default()67 },68 |window, cx| cx.new(|_| Diagram::new(window)),69 )70 .expect("a window");71 cx.activate(true);72 });73}paint leaves the scene’s background to you, so the example fills the element with the theme’s. Options.look sets the theme and the overrides. See Theming. When the look changes, for example when the window switches to dark mode, the scene is only painted again.
To zoom or pan, change the scale and the point you paint at. To fit a diagram to its element, lay it out again with a new max_width when the element’s width changes. A TextMeasurer can be cloned and kept for that. Parse the text once with limu::parse, and lay the diagram out from the Diagram it gives.
Finding the item under the pointer
Scene::item_at finds the item at a point in the scene’s units and returns its index in scene.items. When several items overlap, it returns the one drawn last, which is the one on top. A point hits an item when it’s inside the item’s box or its label’s box. Edges and items made only of lines are hit when the point is within a tolerance of a line or inside one of their markers.
To use it in a window, turn the pointer’s position back into the scene’s units with the origin and scale you painted at. This listener goes in the canvas’s paint closure, after paint, where scale is the scale given to paint and this is cx.entity(), taken in render.
1window.on_mouse_event(move |e: &MouseDownEvent, phase, _, cx| {2 if phase != DispatchPhase::Bubble || e.button != MouseButton::Left {3 return;4 }5 // The pointer in the scene's units.6 let at = Point::new(7 f64::from(e.position.x - bounds.origin.x) / f64::from(scale),8 f64::from(e.position.y - bounds.origin.y) / f64::from(scale),9 );10 let Some(i) = scene.item_at(at, 4.0 / f64::from(scale)) else {11 return;12 };13 let it = &scene.items[i];14 let clicked = format!("{} {}", it.role, it.id.as_deref().unwrap_or(""));15 this.update(cx, |d, cx| {16 d.clicked = clicked;17 cx.notify();18 });19});The tolerance here is 4 pixels, given in the scene’s units. An item’s tooltip holds the text to show when it’s pointed at, and its link holds where it leads. limu-gpui opens no links on its own. Open them the same way your app opens any other link, or don’t.
Fonts
A diagram is laid out with a measurer and drawn with fonts. If the two don’t match, text won’t fit the space layout gave it. limu_gpui::Fonts names two families, sans for text and mono for monospaced text, and bold and italic text is set in the family’s bold and italic faces. Its default is Arial and Courier New, the faces limu measures in when it’s given no measurer.
TextMeasurer measures text as a window’s text system sets it in the fonts. It needs a window, so lay a diagram out once you have one, for example when the window’s view is made. Give the measurer and Options.fonts the same families.
1let fonts = Fonts {2 sans: "Inter".into(),3 mono: "JetBrains Mono".into(),4};5let measurer = TextMeasurer::new(window, fonts.clone());6// Lay out with the measurer, and paint with Options { fonts, .. }.Most text is shaped by the same text system the measurer uses, and painted glyph by glyph on its baseline. GPUI paints glyphs only upright and filled, so two kinds of text are drawn as SVG text instead. These are turned labels, such as an axis title or a swimlane’s name, and the halo around a label’s letters. usvg, the SVG library GPUI draws icons with, shapes that text in the system’s fonts and the fonts your app bundles as assets, so a family you add only to GPUI’s text system isn’t found there.
What GPUI draws differently
limu-gpui draws the same picture as the SVG writer, as far as GPUI lets it.
- GPUI can’t draw a group into a layer of its own, so limu-gpui applies an item’s opacity to each of its colors. Where an item’s parts overlap, the lower part shows through.
- Images are drawn opaque, as GPUI draws images.
- GPUI keeps a font’s metrics to itself, so an underline is drawn a tenth of the text’s size below the baseline and a fifteenth of it thick.
- When a corner radius is too large for a box, limu-gpui uses the smaller of half the width and half the height. SVG uses elliptical corners instead.
Paths are tessellated by GPUI, filled by the nonzero rule, and stroked with SVG’s caps, joins and miter limit.
Icons and images
limu-gpui never loads images on its own. Your app knows where its images come from, so it makes those decisions.
Options.icons gives an icon’s parts by its name, such as fa:fa-car. Every backend of limu-rs takes icons in this one form, limu::scene::Icons, so the icon set written for tiny-skia works here as it is. The parts sit on a box of 24 by 24. A filled part is filled in the color of the item’s text, and a stroked part is stroked in it, 2 wide with round ends and corners. Layout leaves room in labels only for icons you report through layout::Options::icons, so give both the same set.
Options.images gives a GPUI RenderImage for an image’s URL. GPUI keeps an image’s colors in the order blue, green, red, alpha. When an image’s fit is set, it keeps its proportions and is centered in its box. Otherwise it’s stretched to fill the box. Layout only passes through URLs whose scheme layout::Options::image_schemes allows. Without icons or images, icons and images are left out.
1let o = Options {2 fonts,3 icons: Some(Box::new(icon)),4 // images is a HashMap<String, Arc<RenderImage>> of the images loaded already.5 images: Some(Box::new(move |src| images.get(src).cloned())),6 ..Options::default()7};Custom drawing
limu_gpui::paint_with paints each item with a hook in place of the default. The hook receives a Painter, the item and its style as a Resolved, with every color already worked out. The painter’s item paints the item the default way, and parts, ends, picture and label paint the default’s pieces. Its canvas is a GpuiCanvas, whose window gives the window and the app, so the hook can paint with GPUI directly. The hook places what it paints in the window’s pixels itself, with the origin and scale it gave paint_with.
1use gpui::{Bounds, fill, point, px, size};2use limu::scene::Kind;3use limu_gpui::paint_with;45paint_with(&scene, &o, origin, scale, window, cx, |p, it, s| {6 if !matches!(it.kind, Kind::Node) {7 p.item(it, s);8 return;9 }10 // Paint the node as a quad of GPUI's, and its label the default way.11 let b = it.bounds;12 let r = Bounds::new(13 point(origin.x + px(b.x as f32 * scale), origin.y + px(b.y as f32 * scale)),14 size(px(b.w as f32 * scale), px(b.h as f32 * scale)),15 );16 let (window, _) = p.canvas().window();17 window.paint_quad(fill(r, color(s.fill.color)).corner_radii(px(8.0 * scale)));18 p.label(it, s);19});GPUI has no layers, so the colors the hook receives have the item’s opacity in them already.
limu-gpui’s viewer example shows the samples every backend of limu-rs draws in a window, and is a fuller example of painting a scene.