gpui-whiteboard
An infinite, pannable/zoomable whiteboard canvas for GPUI. Shapes, lines, arrows, freehand ink, text, images, and "page cards" on a boundless board — with select / move / resize / rotate / z-order, a built-in toolbar + color picker, templates, copy-paste, and undo/redo.
Host-agnostic: its only dependencies are gpui, serde / serde_json, log, and
ttf-parser (no gpui-component, no native libraries), so it drops into any
GPUI app on macOS, Linux, or Windows. It comes in two layers:
- a plain, serializable scene model (
Scene/ [Element] / …) that you persist as an opaque JSON string in your own store, and - a ready-made
WhiteboardViewentity that renders the board and its whole editing UI (toolbar, flyouts, color picker, templates gallery, right-click menu) and drives all interaction — you supply a theme and a handful of optional callbacks.
Features
- Full editor, not a bare canvas.
WhiteboardViewrenders its own toolbar (pan · select · color │ shapes & text ▾ · pages & images ▾ │ undo · redo · delete), a gradient color picker with host-supplied swatches, tool flyouts, a templates modal, and a right-click context menu. Drop the entity in and it's a working whiteboard. - Rich element set. Freehand pen, rectangle, ellipse, diamond, triangle, rounded rectangle, hexagon, 5-point star, line, arrow, text, images, and page-cards — all share one select / move / resize / rotate / fill machinery.
- Pan / zoom infinite canvas. World-space coordinates with a [
Camera] (pan offset + zoom); drag to pan, scroll/pinch to zoom, snap-to-grid while holding ⌥. - Vector text. Text is rendered as glyph outlines (via
ttf-parser), not gpui overlay glyphs — so it rotates, scales, and z-orders exactly like shapes, and you can swap in a custom/user-uploaded face. JetBrains Mono ships bundled, so the crate works standalone. - Auto-fitting shape labels. Double-click any closed shape (rect / ellipse / diamond / triangle / rounded-rect / star / hexagon) to type a centered label. The text word-wraps and auto-shrinks to fit the shape's inscribed area — staying inside slanted or round outlines (a diamond's center, a triangle's lower wedge), not just the bounding box — and rotates with the shape, with the same caret / selection / clipboard editing as free text. Color it independently of the outline via the picker's Text tab (alongside Stroke / Fill).
- Rich text formatting. Per-character bold, italic, underline, strikethrough, and highlight on any text — free text or shape labels. Toggle via keyboard (⌘B / ⌘I / ⌘U / ⇧⌘X / ⇧⌘H), a right-click Text ▸ fly-out, or the toolbar's A fly-out — each showing a ✓ on the formats active across the selection. Bold and italic are synthetic (so they work with any uploaded face); the styling is stored as runs in the scene and survives edits.
- True z-order. Canvas shapes and image/card overlays paint in one interleaved
stack, so a shape can sit above or below an image. Bring to Front / Forward /
Backward / Send to Back via the menu or
⌘]/⌘[(± ⇧). - Copy / paste / templates.
⌘C/⌘X/⌘Vand a right-click Copy/Cut/Paste, plus reusable named templates — both serialize a selection to the same portable JSON, so groups move across boards and windows. - Undo / redo, multi-select (marquee + shift-click), group move/resize, and a rotate grip on the selection.
- Theme-reactive. Colors come from a
Fn() -> WhiteboardStyleclosure read at paint time, so the board follows live theme / light-dark changes (and can differ per window) with no push from the host. - You own persistence, files, and navigation. The crate never touches disk, the clipboard, or your page store. It calls back to you (hooks) to fetch an image bitmap, open a page, read/write the clipboard, or persist the scene — and hands you a plain JSON string to store however you like.
Quick start
use Rc;
use ;
// Build the view over a scene (a fresh `Scene::default()` or `Scene::from_json`
// of a stored board). Call inside `cx.new(..)`.
let board = cx.new;
// Render it like any entity:
div.size_full.child
That alone gives a fully usable board (every tool, color picker, undo/redo, z-order, copy/paste between boards). Wire the optional hooks to add page-cards, images, templates, and system-clipboard paste.
Embedding in a rich-text editor
When you embed the board inside a larger editor, the most common integration mode is read-only preview + viewport movement.
Use the dedicated constructor:
use Rc;
use ;
let board = cx.new;
Or switch an existing board at runtime:
board.update;
read_only = true means the board behaves like a forced move tool:
- left-drag pans the canvas
- selection / editing / creation are ignored
- external
set_tool(..)calls are coerced back to pan mode
If you want a ready-made embed surface with an "edit / maximize" affordance, use
BoardEmbedView:
use Rc;
use ;
let embed = cx.new;
Recommended responsibility split:
gpui-whiteboardowns the embedded preview surface and the "Edit" button- your editor owns the actual maximize / modal / split-pane transition
For local thumbnails inside a document block, use the snapshot + thumbnail view pair:
use Rc;
use ;
let snapshot = board.read.local_thumbnail_snapshot;
let thumb = snapshot.map;
This renders a chrome-free local preview:
- background grid
- shapes / text / connectors
- page cards
- image placeholders
It intentionally does not render the toolbar, selection handles, or editing chrome.
API
WhiteboardView
A gpui entity (impl Render) that owns the scene, the current tool, selection,
in-progress edits, undo history, and the entire editing UI. Store the
Entity<WhiteboardView> and render it in a tab/panel.
Construction
| Method | Signature | Purpose |
|---|---|---|
new |
fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self |
Build a view over scene. style is read at paint time (see WhiteboardStyle). Call inside cx.new(|cx| …). |
new_read_only |
fn new_read_only(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self |
Build a read-only board view for embedding/preview. |
Imperative controls (most boards never need these — the built-in toolbar/keys drive them — but they're here for custom chrome):
| Method | Signature | Purpose |
|---|---|---|
tool / set_tool |
fn tool(&self) -> Tool · fn set_tool(&mut self, tool: Tool, cx: &mut Context<Self>) |
Read / set the active Tool. |
read_only / set_read_only |
fn read_only(&self) -> bool · fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) |
Read / toggle forced-pan read-only mode. |
zoom_in / zoom_out / reset_view |
fn …(&mut self, cx: &mut Context<Self>) |
Zoom about the viewport center; reset_view returns to 100% at the origin. |
undo / redo |
fn …(&mut self, window: &mut Window, cx: &mut Context<Self>) |
Step the history. (⌘Z / ⌘⇧Z do this already.) |
scene |
fn scene(&self) -> &Scene |
Borrow the current model — e.g. to persist after an add_embed/add_image_at (which don't auto-fire on_change). |
viewport_center |
fn viewport_center(&self) -> [f32; 2] |
The world point at the center of the viewport — where pastes/templates land. |
local_thumbnail_spec |
fn local_thumbnail_spec(&self, width_px: f32, height_px: f32) -> Option<LocalThumbnailSpec> |
Build the default local-thumbnail focus spec. |
local_thumbnail_spec_for_mode |
fn local_thumbnail_spec_for_mode(&self, mode: LocalThumbnailMode, width_px: f32, height_px: f32) -> Option<LocalThumbnailSpec> |
Build a local-thumbnail focus spec for an explicit focus mode. |
local_thumbnail_snapshot |
fn local_thumbnail_snapshot(&self, width_px: f32, height_px: f32) -> Option<LocalThumbnailSnapshot> |
Capture a serializable thumbnail snapshot (scene + spec). |
local_thumbnail_snapshot_for_mode |
fn local_thumbnail_snapshot_for_mode(&self, mode: LocalThumbnailMode, width_px: f32, height_px: f32) -> Option<LocalThumbnailSnapshot> |
Capture a snapshot for an explicit focus mode. |
BoardEmbedView
A small host-facing wrapper around a read-only WhiteboardView, intended for rich
text / document embedding.
| Method | Signature | Purpose |
|---|---|---|
new |
fn new(scene: Scene, style: WhiteboardStyleFn, cx: &mut Context<Self>) -> Self |
Build a read-only embedded board preview. |
board |
fn board(&self) -> Entity<WhiteboardView> |
Access the inner board entity for host-driven inspection or updates. |
set_on_expand |
fn set_on_expand(&mut self, f: ExpandEmbedFn) |
Install the callback fired when the embed's "编辑" button is clicked. |
BoardThumbnailView
A lightweight, read-only local thumbnail renderer built from a
LocalThumbnailSnapshot.
| Method | Signature | Purpose |
|---|---|---|
new |
fn new(snapshot: LocalThumbnailSnapshot, style: WhiteboardStyleFn) -> Self |
Build a chrome-free thumbnail view. |
snapshot |
fn snapshot(&self) -> &LocalThumbnailSnapshot |
Read the current thumbnail snapshot. |
set_snapshot |
fn set_snapshot(&mut self, snapshot: LocalThumbnailSnapshot) |
Replace the rendered snapshot without rebuilding the surrounding host UI. |
Thumbnail focus types
LocalThumbnailMode controls what the board focuses when building a local
thumbnail:
Auto: selected content first, otherwise current viewport, otherwise all contentSelection: current selection onlyViewport: current camera viewportAllContent: all scene elementsElement(id): one explicit element
LocalThumbnailSpec returns:
anchor_element_idfocus_boundsscene_boundscamera
LocalThumbnailSnapshot packages:
scenespec
Hosts that only have persisted scene JSON can build a document thumbnail without mounting a full board entity first:
let scene = from_json;
let snapshot = for_scene_all_content;
let thumbnail = cx.new;
Building elements from the host (called after a place-hook fires; see
hooks). These run mid-host-update and so do not fire
on_change — persist explicitly via scene() afterward:
| Method | Signature |
|---|---|
add_embed |
fn add_embed(&mut self, page_id: i64, title: impl Into<String>, x: f32, y: f32, cx: &mut Context<Self>) |
add_image_at |
fn add_image_at(&mut self, src: impl Into<String>, px_w: f32, px_h: f32, cx_world: f32, cy_world: f32, cx: &mut Context<Self>) |
paste_elements |
fn paste_elements(&mut self, json: &str, window: &mut Window, cx: &mut Context<Self>) |
add_image_at sizes the element from the image's pixel dimensions (px_w/px_h,
aspect preserved) centered on (cx_world, cy_world). paste_elements stamps a
serialized selection (from a clipboard read) centered in the viewport.
WhiteboardStyle
The board reads its palette through a Fn() -> WhiteboardStyle each paint (not
stored), so returning fresh values tracks live theme changes.
pub type WhiteboardStyleFn = ;
Host hooks
All optional — install with the matching set_* method after new. Each is an
Rc<dyn Fn(...)>; the board works with none installed (you just lose that feature).
Coordinates passed to hooks are world-space (see [Camera]).
| Setter | Type | Fires when… | You should… |
|---|---|---|---|
set_on_change |
ChangeFn = Fn(String, &mut Window, &mut App) |
the board changes (element committed/moved/deleted, camera moved) | persist the scene JSON string |
set_on_place_embed |
PlaceEmbedFn = Fn(f32, f32, &mut Window, &mut App) |
the page-card tool is clicked at (x, y) |
pick a page, then call add_embed(page_id, title, x, y, cx) |
set_on_open |
OpenPageFn = Fn(i64, &mut Window, &mut App) |
a page-card is double-clicked | open that page (page_id) in your app |
set_on_image |
ImageFn = Fn(&str, f32, &mut Window, &mut App) -> Option<ImageSource> |
each paint, per image element | return the decoded bitmap for src rotated by the f32 radians (decode off-thread; None until ready, then re-render) |
set_on_place_image |
PlaceImageFn = Fn(f32, f32, &mut Window, &mut App) |
the image tool is clicked at (x, y) |
pick a file, import it, then call add_image_at(...) |
set_on_drop_files |
DropFilesFn = Fn(Vec<PathBuf>, f32, f32, &mut Window, &mut App) |
files are dropped on the canvas at (x, y) |
import any images and place them via add_image_at(...) |
set_on_copy |
CopyFn = Fn(String, &mut Window, &mut App) |
⌘C / ⌘X with a selection |
write the serialized selection to the system clipboard |
set_on_paste |
PasteFn = Fn(&mut Window, &mut App) -> Option<String> |
the context-menu Paste | read the clipboard; return previously copied board JSON, or None |
set_on_save_template |
SaveTemplateFn = Fn(String, &mut Window, &mut App) |
the user saves a selection as a template | name + store it, then feed the list back via set_templates |
set_on_delete_template |
DeleteTemplateFn = Fn(i64, &mut Window, &mut App) |
a template card is right-clicked → delete | remove it (by id), then set_templates |
set_on_save_colors |
SavedColorsFn = Fn(Vec<u32>, &mut Window, &mut App) |
the user adds/removes a swatch in the picker's Saved palette | persist the packed 0xRRGGBBAA list, then push it back via set_saved_colors |
set_on_pick_font |
PickFontFn = Fn(FontPick, &mut Window, &mut App) |
the Aa Font flyout's Upload / Use default is clicked | load the .ttf/.otf (or the default) and call set_font — and persist the per-board choice |
set_on_move_toolbar |
MoveToolbarFn = Fn(Option<(f32, f32)>, bool, &mut Window, &mut App) |
the toolbar is dragged, reset (double-click the grip), or flipped row↔column | persist its new board-relative top-left (None = default top-center) and orientation (bool = vertical) |
set_templates |
fn(&mut self, Vec<Template>, &mut Context<Self>) |
— | push the current template list (on open and after any save/delete) |
set_saved_colors |
fn(&mut self, Vec<u32>, &mut Context<Self>) |
— | push the user's saved-color palette (on open and after a change) |
set_toolbar_pos |
fn(&mut self, Option<(f32, f32)>, &mut Context<Self>) |
— | push the saved toolbar position (None = default top-center) on open and after a change |
set_toolbar_vertical |
fn(&mut self, bool, &mut Context<Self>) |
— | push the saved toolbar orientation (vertical = a column) on open and after a change |
set_font |
fn(&mut self, Font, &mut Context<Self>) |
— | swap the text face (see Custom fonts) |
Image & clipboard flow. Images aren't stored in the scene — only a
srcreference is. The crate asks for the bitmap viaImageFneach paint; you own the file store and the cache (decode off-thread, downscale, manage the GPU texture). Copy/paste likewise routes the bytes throughCopyFn/PasteFnso the system clipboard stays the source of truth (and⌘Vprefers copied elements over a clipboard image). Templates persist throughSaveTemplateFn/set_templates.
The scene model
A Scene is the board's persisted state — a [Camera] plus a Vec<Element> in
paint order (earlier = behind). It's plain serde data: store
view.scene().to_json() and reload with Scene::from_json(&s) (which never panics —
empty/garbage yields a blank board). Element colors are packed 0xRRGGBBAA u32s.
// pan offset + zoom
// world point under a canvas point `s`: camera.offset + s / zoom
rotation is radians clockwise about the element's center. All geometry is
world-space; multiply by camera.zoom and subtract the pan offset for screen space.
Tool
Pan is the default. The view renders a toolbar for these and handles their
single-key shortcuts itself; use set_tool only if you drive tools from your own UI.
Templates
A reusable group of elements, stamped centered in the viewport. The crate renders the
preview gallery and instantiates on click; you own storage and the id.
Custom fonts
Text is drawn from glyph outlines, so any TrueType/OpenType face works. The default is
bundled (JetBrains Mono, OFL); swap one in directly with set_font:
use Font;
if let Some = from_bytes
// Font::default() is the bundled face.
For a user-facing picker, install set_on_pick_font: the toolbar then shows an Aa
button whose flyout offers Upload font… and Use default. The crate hands you a
FontPick (Upload / Default); you run the file dialog, build the face, call
set_font, and persist the choice however you like (the host app keeps one face per
board, restored on reopen).
Keyboard & mouse
The view handles these when it has focus (it focuses on a canvas click):
| Input | Action |
|---|---|
H V P R O D G U S X L A T I |
pick a tool (pan, select, pen, rect, ellipse, diamond, triangle, rounded-rect, star, hexagon, line, arrow, text, image) |
⌫ / Delete |
delete the selection |
⌘Z / ⌘⇧Z |
undo / redo |
⌘C / ⌘X / ⌘V |
copy / cut / paste the selection |
⌘] / ⌘[ |
bring forward / send backward (add ⇧ for to-front / to-back) |
Esc |
deselect (or close the color picker / templates modal) |
| drag (Pan tool) · middle-drag | pan the canvas |
| scroll · pinch | zoom |
hold ⌥ while dragging |
snap to the grid |
| click / shift-click / marquee-drag (Select tool) | select one / add / box-select |
| drag a handle · the round grip above a selection | resize (corners scale; edge handles stretch one axis, on a single element or a group) · rotate |
| double-click a page-card | open its page (via OpenPageFn) |
double-click text · T-click text |
edit it — click a letter for the caret, drag / double-click to select |
| double-click a closed shape | edit its centered label — wraps + auto-shrinks to fit; full caret / selection / clipboard |
⌘B ⌘I ⌘U ⇧⌘X ⇧⌘H (editing text) |
bold / italic / underline / strikethrough / highlight the selection (toggle) |
| drag the dotted grip (left of the toolbar) | move the toolbar (tap R mid-drag to flip row ↔ column; double-click the grip resets it) |
| right-click | context menu (z-order, copy/cut/paste, save as template); while editing text, a Text ▸ fly-out toggles bold / italic / underline / strike / highlight (✓ marks active) |
While editing a text element it behaves like a normal text field: click to place the caret, click-drag or double-click to select, arrows / Home / End (⇧ extends), ⌘A, and ⌘C / ⌘X / ⌘V on the system clipboard; Esc (or a click away) commits.
Persistence
The crate is storage-agnostic. Persist a board by storing the string from on_change
(or view.scene().to_json()); reload with WhiteboardView::new(Scene::from_json(&s), …).
Images and templates live in your store — the scene only references images by src,
and templates round-trip through your SaveTemplateFn + set_templates.
Status
Pre-1.0 (0.1). The scene JSON is forward-leaning — new fields use serde defaults, so
older boards keep loading — but the API may still shift before 1.0. Performance note:
elements are re-tessellated each paint (as GPUI's own painting examples do); a
built-Path cache + viewport culling is the planned optimization once boards get
large.
License
GPL-3.0-or-later. The bundled default font (JetBrains Mono) is under the SIL Open Font
License — see assets/JetBrainsMono-OFL.txt.