@grida/hud
v0.2.0
Published
Canvas-based heads-up display for the Grida editor viewport
Maintainers
Readme
@grida/hud
The surface backend for the Grida editor viewport.
A self-contained, mathematically grounded HUD overlay: handles, selection chrome, hover, marquee, gesture state, hit-testing — all rendered to a single <canvas> from a pure-logic state machine. No DOM overlay, no data-id traversal, no per-element React reconciliation in the hot path.
What is the HUD?
The HUD is the non-content visual chrome drawn on top of the viewport: selection rectangles, resize handles, hover outlines, marquees, snap guides, measurement lines, pixel grids. Everything the operator sees that isn't part of the document itself.
In industry terms: Blender calls this "Overlays", Unity calls it "Gizmos", game engines call it "HUD". We use HUD.
Why a canvas-based surface
The DOM approach — positioned <div> handles, data-grida-id for hit-test, native dblclick — works until it doesn't. Concrete failures we hit before this package existed:
- DOM identity is fragile. Re-rendering the SVG tree on every state change destroys the live element mid-gesture; native dblclick (keyed by DOM identity) breaks and has to be re-implemented manually.
- Hit-test relies on attributes.
data-grida-idhas to be applied and stripped around export; any path-rewrite invalidates the id index. - State and events are hard to propagate. Gesture, hover, modifier, selection state scatter across the surface and editor; each one re-renders the world.
- It does not scale across backends. SVG-tied DOM overlays can't be reused by the cg/Rust editor.
The fix is to make the HUD a pure-logic state machine + a canvas renderer. The state machine takes raw pointer events, owns gesture/hover/modifiers, and emits Intents to the host. The renderer draws a single HUDDraw per frame. Both layers are testable in isolation; neither layer touches the DOM.
Architecture
Three layers. One-directional dependency: primitives/ ← event/ ← surface/.
┌─────────────────────────────────────────────────┐
│ Host (svg-editor, grida-canvas-react, …) │
│ - Document, scene, selection │
│ - Provides: pick, shapeOf, onIntent │
│ - Pushes pointer events to surface │
│ - Commits intents (history.preview, commands) │
└───────────────────────┬─────────────────────────┘
│
┌───────────────────────▼─────────────────────────┐
│ surface/ — the wired class │
│ - `Surface`: constructor wiring, lifecycle, │
│ draw loop │
│ - `chrome`: builds HUDDraw from state │
└───────────┬───────────────────────┬─────────────┘
│ │
┌───────────▼──────────┐ ┌──────────▼─────────────┐
│ event/ │ │ primitives/ │
│ the math core │ │ dumb render shapes │
│ - Gesture state │ │ - HUDCanvas │
│ - Hit-regions │ │ - HUDDraw primitives │
│ - Click-tracker │ │ - Snap/measure/lasso │
│ No canvas. No host. │ │ builders │
│ No DOM. │ │ No state. No host. │
└──────────────────────┘ └────────────────────────┘The event/ layer is the testable core. It dispatches plain objects, returns plain objects, has zero I/O. Every gesture transition, hit-region resolution, and click-tracker decision is a pure function over its inputs — runnable under vitest with no canvas, no DOM, no React.
Layer responsibilities
| Layer | Owns | Reads from | Writes to |
| -------------------------- | ------------------------------------------------------------------------------ | ----------------------------- | ---------------------------------------------------------------- |
| primitives/HUDCanvas | Canvas2D context, DPR, transform, drawing of HUDDraw primitives | — | Canvas |
| event/ | Gesture, hover, modifiers, cursor, click-tracker, hit-regions, intent builders | — | Returns next state + intents from dispatch(state, event, deps) |
| surface/Surface | Current SurfaceState, the underlying HUDCanvas, host providers, draw loop | Host providers, surface state | Calls onIntent; calls HUDCanvas.draw |
| Host | Document, scene, selection, history | Surface state introspection | Pushes pointer events; pushes selection mirror; commits intents |
What the surface deliberately does NOT own
- Selection. Host owns; surface holds a read-only mirror via
setSelection(ids). Surface emitsselect/deselect/toggleintents; host commits. - Document / scene. Surface never reads node data directly. Only callbacks:
pick(point) → NodeId | null,shapeOf(id) → SelectionShape | null. - History. Host wraps commits with
history.previewper its own logic. Intents carryphase: "preview" | "commit"so the host knows when to wrap. <style>resolution, defs, paint, anything SVG-specific. Out of scope.
What the surface owns because nothing else needs it
Gesture state machine, hover indicator, modifier snapshot, cursor, click-tracker (dblclick). These have no representation outside the surface; making them surface-private is the single-source-of-truth rule.
Two backends: render and hit-testing
The HUD has two backends, and they deliberately disagree.
Every frame the HUD produces two outputs: a list of render primitives drawn to the canvas, and a registry of hit regions consulted on pointer events. They share no geometry. They are paired per affordance — the rotation handle's hit region is not derived from a rendered shape, and the resize knob's visual is not derived from its hit AABB. One backend is for the eye; the other is for the cursor.
Why they disagree
The two outputs optimise for different things:
- The renderer optimises for legibility at any zoom — small, crisp shapes that don't dominate the document.
- The hit-tester optimises for Fitts'-law reach — fat targets, virtual regions outside the visible shape, priority ladders that resolve overlap by intent rather than topology.
Collapsing them — drawing a 16 px knob just to match the 16 px hit AABB, or shrinking the hit AABB to 8 px just to match the visual — breaks one of the two. The split exists so neither has to compromise.
OverlayElement — the pairing primitive
OverlayElement is the public type that holds the discipline together. Each element carries a mandatory hit and an optional render. Hit is required because every overlay must be reachable; render is optional because some overlays are deliberately invisible.
The renderer never reads from hit. The hit-tester never reads from render. The asymmetry is the point.
Four families of overlay
The mental model the package operates under. Every overlay the HUD draws — or the next one anyone adds — falls into one of four shapes:
| Family | Visual | Hit region | Example | | --------------- | ----------------- | ---------------------------------------- | ------------------------------------ | | Paired | Drawn shape | Same shape, padded for reach | Corner resize knob, line endpoint | | Virtual | None | Region the user can click but never sees | Rotation handles, edge-resize strips | | Decorative | Drawn shape | None | Snap pips, measurement labels | | Body-region | Selection outline | Inner region that triggers translate | Selected shape body |
Anything new the HUD learns to do should be a deliberate choice between these — not a side effect of how it happened to be drawn.
Guidance for new affordances
- Decide hit geometry first, visual second. The user reaches for the hit region; the visual is a hint about where it is.
- If an affordance is virtual, omit
render. Don't draw a stand-in just because the type allows it. - If the visual is smaller than the minimum comfortable touch target, pad the hit region. Don't pad the visual to compensate.
Guidance for tests
Tests should assert against hit and render separately. A test that only checks the rendered shape silently passes when someone removes the hit padding; a test that only checks the hit region silently passes when someone drops the visual.
New affordances should add at least one assertion per side, and — where the two intentionally differ — one assertion that they differ in the expected direction (e.g. hit strictly contains the render bbox).
Public API
import { Surface, HUDCanvas, type HUDDraw, type HUDStyle } from "@grida/hud";
const surface = new Surface(canvasElement, {
// required wiring
pick: (point_doc) => editor.hitTest(point_doc), // (point) => NodeId | null
shapeOf: (id) => editor.shapeOf(id), // (id) => SelectionShape | null
onIntent: (intent) => editor.commitIntent(intent), // surface → host
// optional config
style: { chromeColor: "#2563eb", handleSize: 8 },
readonly: false,
});
// Lifecycle
surface.setSize(w, h);
surface.setTransform(t); // camera (screen ↔ doc)
surface.setSelection(ids); // read-only mirror from host
surface.setStyle(partial);
surface.setReadonly(v);
surface.setPixelGrid({ enabled, zoomThreshold, color?, steps? }); // or null to disable
surface.dispose();
// Input
const response = surface.dispatch(event);
// response: { needsRedraw, cursorChanged, hoverChanged }
// (intents are pushed via onIntent, not returned here)
// Frame
surface.draw(extra?); // merges surface chrome + host extras, one canvas draw
// Read-only introspection
surface.gesture(): SurfaceGesture;
surface.hover(): NodeId | null;
surface.cursor(): CursorIcon;Event types
type SurfaceEvent =
| { kind: "pointer_move"; x: number; y: number; mods: Modifiers }
| {
kind: "pointer_down";
x: number;
y: number;
button: PointerButton;
mods: Modifiers;
}
| {
kind: "pointer_up";
x: number;
y: number;
button: PointerButton;
mods: Modifiers;
}
| { kind: "modifiers"; mods: Modifiers }
| {
kind: "wheel";
x: number;
y: number;
dx: number;
dy: number;
mods: Modifiers;
}
| { kind: "key"; phase: "down" | "up"; code: string; mods: Modifiers };
type Modifiers = { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
type PointerButton = "primary" | "secondary" | "middle";All SurfaceEvent coordinates are screen-space CSS pixels relative to the canvas. The surface owns the camera and converts internally.
Gesture state
type SurfaceGesture =
| { kind: "idle" }
| { kind: "pan"; dx: number; dy: number }
| { kind: "marquee"; rect: Rect } // screen-space
| { kind: "translate"; ids: NodeId[]; dx: number; dy: number }
| {
kind: "resize";
ids: NodeId[];
direction: ResizeDirection;
initial_shape: SelectionShape;
current_shape: SelectionShape;
}
| {
kind: "rotate";
ids: NodeId[];
corner: RotationCorner;
anchor_angle: number;
current_angle: number;
}
| { kind: "endpoint"; id: NodeId; endpoint: "p1" | "p2" };Intents
The surface emits Intents through onIntent. The host commits — wrapping in history.preview, dispatching commands, whatever. The surface itself doesn't mutate the document.
type Intent =
| { kind: "select"; ids: NodeId[]; mode: "replace" | "add" | "toggle" }
| { kind: "deselect_all" }
| { kind: "translate"; ids: NodeId[]; dx: number; dy: number; phase: Phase }
| {
kind: "resize";
ids: NodeId[];
anchor: ResizeDirection;
/** AABB of the new shape (for axis-aligned hosts). */
rect: Rect;
/** Full new shape — `transformed` carries the matrix so rotated
* hosts can resize in the local frame. Optional for backward-compat. */
shape?: SelectionShape;
phase: Phase;
}
| { kind: "rotate"; ids: NodeId[]; angle: number; phase: Phase }
| {
kind: "marquee_select";
rect: Rect;
additive: boolean;
phase: Phase;
}
| {
kind: "set_endpoint";
id: NodeId;
endpoint: "p1" | "p2";
pos: [number, number];
phase: Phase;
}
| { kind: "enter_content_edit"; id: NodeId }
| { kind: "cancel_gesture" };
type Phase = "preview" | "commit";Why phase matters. During a drag, the surface emits phase: "preview" on every frame; on pointer-up, one final phase: "commit". The host wraps preview intents in history.preview() (apply + revert) and finalizes on commit. This removes guesswork from the host — gesture state is not part of the intent contract.
React API
A single hook. No provider, no context.
import { useHUDSurface } from "@grida/hud/react";
function Viewport() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const surface = useHUDSurface(canvasRef, {
pick,
shapeOf,
onIntent,
style,
});
// surface is the imperative instance — same as `new Surface(...)`
// wire pointer events as the host wants
return <canvas ref={canvasRef} />;
}Cursors
The HUD owns cursor state (SurfaceState.setCursor → surface.cursor()), but does not own cursor pixels. The host receives a CursorIcon and decides what CSS cursor: value to apply.
For hosts that want Grida's default Figma-style rotation/resize cursors — curved double-arrows for rotate, straight double-arrows for resize, both following the selection's screen-space rotation — wire the opt-in renderer from the dedicated subpath:
import { cursors } from "@grida/hud/cursors";
surface.setCursorRenderer(cursors.defaultRenderer());Tree-shake invariant. Nothing in surface/, event/, or primitives/ may import from cursors/. Hosts that don't import the subpath pay zero bundle cost. Enforced by __tests__/cursors.test.ts.
The subpath also exposes the SVG templates and the data: URL encoder for hosts that want to render cursor previews in sidebar UI without going through the Surface:
import { cursors } from "@grida/hud/cursors";
const svg = cursors.templates.rotate(45); // angle in degrees
const url = cursors.svgDataUrl(svg);Selection intent
The full classification table — every named scenario at pointer-down, what commits when, and why DOM event order can't express it cleanly — is an implementation-agnostic working-group spec, alongside its UX-narrative sibling:
- Formal classifier: https://grida.co/docs/wg/feat-editor/ux-surface/selection-intent
- UX-narrative test cases: https://grida.co/docs/wg/feat-editor/ux-surface/selection
This package's implementation:
event/decision.ts—Scenarioenum +classifyScenario+ dispatch.__tests__/decision.test.ts— one test per named scenario.
Skim the spec before changing the classifier or its dispatch.
Coordinate model
All
SurfaceEventpoints are screen-space (CSS px relative to the canvas).setTransform(t)is the camera — one matrix, screen ↔ doc.Hit-test happens in two tiers on
pointer_down:- UI layer (screen-space AABB) — surface's own
HitRegionsregistry, populated by chrome builder each frame. Resize handle? Rotation handle? Body region (translate)? Direct path; no host involvement. - Scene layer (doc-space point) — if no UI hit, surface converts the point screen→doc and calls
pick(point_doc). Host implements this with whatever it has (elementFromPoint+data-idfor SVG-DOM hosts, scene-cache R-tree for cg).
- UI layer (screen-space AABB) — surface's own
The UI-tier hit registry is built independently of the render path — see Two backends: render and hit-testing.
shapeOf(id)returns a doc-spaceSelectionShape:{ kind: "rect", rect }— axis-aligned (most nodes){ kind: "line", p1, p2 }— vector lines{ kind: "transformed", local, matrix }— anything with a non-identity 2×3 affine (rotation, skew, non-uniform scale, mirror).localis the artwork's local-frame AABB;matrixmaps local → doc. Identity matrix here is byte-equivalent to{ kind: "rect", rect: local }.
See Transformed selections below for what the HUD does with the
transformedvariant.
Handles are always drawn at a fixed screen-space size regardless of zoom. The primitive layer supports this via HUDScreenRect (see below).
Render path (one draw per frame)
- Surface builds its own
HUDDrawfromSurfaceState+shapeOf:- hover outline (if
hover()set) - selection outline (from selection mirror)
- marquee rect (if active gesture is
marquee) - resize / rotate handles (screen-space)
- size meter pill (selection bounds W×H)
- hover outline (if
- Host extras merge in if
draw(extra)was called with a host-fedHUDDraw(snap guides, measurement, custom widgets). - One call to
HUDCanvas.draw(merged)— clears the canvas and renders everything immediately.
Layer order within a frame (back-to-front):
- Pixel grid (when enabled and
transform[0][0] > zoomThreshold) - Hover outline
- Selection outline
- Marquee rect
- Resize/rotate handles
- Size meter pill
- Host-fed extras (always on top)
Transformed selections
When a host returns { kind: "transformed", local, matrix } from shapeOf, the HUD renders the chrome — outline, knobs, edge strips, rotation halos, size badge, dashed resize preview — in the artwork's own frame. Knobs rotate with the parent. The size badge reads local.width × local.height, not the AABB of the rotated rect. The cursor's baseAngle follows the matrix so resize/rotate arrows stay aligned with the selection's tilt.
Render uses lazy transforms — every rotated primitive carries an optional angle field; the canvas wraps the draw call in a translate/rotate/restore:
| Primitive | Field | Effect |
| --------------- | ------------------------------------ | ------------------------------------------------------ |
| HUDScreenRect | angle?: number (radians, CCW) | Rotates the rect around its screen-space center. |
| HUDLine | labelAngle?: number (radians, CCW) | Rotates the label pill around its screen-space center. |
Hit-test uses the same lazy transform via a new HitShape variant:
type HitShape =
| { kind: "screen_rect_at_doc"; anchor_doc; width; height }
| { kind: "screen_aabb"; rect }
| { kind: "screen_obb"; rect; inverse_transform }; // ← newscreen_obb carries the un-rotated zone rect (in shadow space, centered at the chrome's screen center) plus an inverse_transform that maps a screen-space pointer INTO shadow space. The hit-test loop applies the inverse to the pointer, then runs the usual AABB containment. No AABB-of-rotated-corners inflation — clicks outside the visible rotated chrome don't trigger phantom resize regions, regardless of aspect ratio or rotation. The 9-slice priority ladder operates in the same coordinate frame as the axis-aligned rect path, so promotion/demotion rules behave identically.
The resize gesture operates in the local frame: applyResize takes a SelectionShape and returns a SelectionShape, with deltas inverse-transformed into local space for transformed shapes. The emitted Intent carries both rect (AABB) and shape (full local + matrix) so legacy axis-aligned hosts keep working while transform-aware hosts can write the new dims back into the artwork without touching the matrix.
v1 caveats. Pure rotation is exact at every level (render, hit, gesture). Skew and non-uniform scale render correctly but use a uniform-scale fallback for handle sizing — anisotropic per-axis sizing is a follow-up.
Primitives — what HUDCanvas can draw
| Primitive | Coordinate space | Used by |
| --------------- | ------------------------------------------------------ | --------------------------------------------- |
| HUDLine | doc-space (segment), optional label in screen-space | snap lines, measurement, spacing |
| HUDRule | doc-space offset, full-viewport extent in screen-space | guide snapping, ruler |
| HUDRect | doc-space | selection outline, marquee in doc-space hosts |
| HUDPolyline | doc-space | lasso, complex selections |
| HUDScreenRect | screen-space size at doc-space anchor | resize/rotate handles |
| Crosshair point | screen-space | snap hit indicators |
| Label pill | screen-space at doc-space anchor | size meters, snap gap labels |
HUDDraw is a plain command struct grouping these. Builders (snapGuideToHUDDraw, measurementToHUDDraw, …) take host state and return a HUDDraw — the surface uses the same mechanism internally for its chrome.
Every primitive may also carry an optional semantic group string. The HUD package does not define the vocabulary; hosts name their own groups, assign them to surface chrome via SurfaceOptions.groups, and return hidden groups from SurfaceOptions.visibility. Groups are visibility policy, not paint order, so a host can suppress whole UI families during a gesture while leaving unrelated extras alone.
Module layout
packages/grida-canvas-hud/
├── README.md
├── index.ts # Surface, HUDCanvas, public types
├── react.tsx # useHUDSurface hook
├── primitives/ # UI — dumb render shapes
│ ├── canvas.ts # HUDCanvas
│ ├── types.ts # HUDDraw, HUDLine, HUDRect, HUDPolyline, HUDRule, HUDScreenRect
│ ├── snap-guide.ts # HUDDraw builder
│ ├── measurement-guide.ts # HUDDraw builder
│ ├── marquee.ts # legacy HUDDraw builder (surface emits its own marquee)
│ └── lasso.ts # HUDDraw builder
├── event/ # the math core
│ ├── event.ts # SurfaceEvent, Modifiers, PointerButton
│ ├── gesture.ts # SurfaceGesture + transitions
│ ├── hit-regions.ts # screen-space AABB region registry
│ ├── handles.ts # 8 resize + 4 rotate geometry & hit-test
│ ├── click-tracker.ts # dblclick / multi-click detection
│ ├── cursor.ts # CursorIcon, ResizeDirection, RotationCorner
│ ├── intent.ts # Intent types + builders
│ ├── transform.ts # screen ↔ doc helpers
│ └── state.ts # SurfaceState — pure dispatch entry
├── surface/ # the wired class
│ ├── surface.ts # Surface class
│ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
│ └── style.ts # HUDStyle defaults + merge
└── cursors/ # opt-in subpath: @grida/hud/cursors
├── index.ts # cursors.defaultRenderer(), templates, encoder
├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
├── templates.ts # parameterized SVG cursor templates
└── encode.ts # SVG → data: URLNaming conventions
- File names are kebab-case
.tsthroughout. - Inside files, snake_case is acceptable for non-filename identifiers.
- Public method names on
SurfaceandHUDCanvasare camelCase (setSize,setTransform,setSelection) — matches the existing primitive renderer API. - The top-level class is
Surface, notHUDSurface. The package name already says "hud".
Testing
packages/grida-canvas-hud/__tests__/ runs under vitest. No DOM, no canvas mock — the event/ layer is pure.
| File | Tests |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| transform.test.ts | screen ↔ doc across translate, scale, DPR |
| hit-regions.test.ts | topmost wins, reverse iteration, clear/empty, AABB containment |
| handles.test.ts | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
| click-tracker.test.ts | single vs double within window; position threshold; multi-button isolation |
| gesture.test.ts | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
| state.test.ts | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
| intent.test.ts | intent stream + phase correctness across a full drag (preview*N → commit) |
| chrome.test.ts | given state + bounds, assert resulting HUDDraw shape — primitive counts and coords |
| chrome-transformed.test.ts | SelectionShape.transformed end-to-end: outline, knob anchors, OBB hit-test exactness, identity ≡ rect equivalence |
| cursors.test.ts | cursors.defaultRenderer produces rotation-aware CSS values; tree-shake invariant (subpath isolated from main bundle) |
| decision.test.ts | one test per named scenario in the selection-intent classifier |
Render output (visual canvas correctness) is verified in the browser, not unit-tested.
Extending the HUD
The HUD intentionally exposes no generic "register a painter" or "register a layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real needs, in this order of preference:
Named built-in chrome. Things every Grida editor wants — pixel grid, selection, snap guides, measurement — live inside this package as first-class features with their own toggles (e.g.
setPixelGrid,setStyle, the chrome built fromSurfaceState). New canonical chrome lands here; open a PR against@grida/hud.Host-fed
HUDDrawextras. Pass extra primitives intosurface.draw(extra)per frame. Best for transient, gesture-coupled overlays the host already computes (measurement lines, custom snap visualizers). Drawn on top of named chrome — they're foreground, not background.DOM-level escape hatch. The host owns the container element; the surface only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay (HTML toolbar, popover, debug widget) can splice their own DOM into the container directly. Deliberate escape hatch — reach for it only when (1) and (2) don't fit, and prefer pushing canonical needs into (1) over keeping them here.
Anti-goals
- Not a renderer.
primitives/HUDCanvasis intentionally minimal Canvas2D. Skia / WebGL backends are not in scope. - Not a scene graph. Surface never reads node data — only via
pick/shapeOf. - Not a host of plugins. No widget registry. Custom HUD elements go through host-fed
HUDDrawextras. - Not undo-aware. Intents carry
phase; host owns undo. - Not a selection store. Host owns selection; surface mirrors.
- Not SVG-aware. No
data-id, no DOM IR, no<style>resolution.
Adoption
v1 ships against @grida/svg-editor. The svg-editor's dom.ts is rewritten as a thin adapter: SVG content on the bottom layer, a <canvas> HUD on top, pointer events forwarded to surface.dispatch, intents flowing back via editor.commitIntent. Selection chrome, handles, and gesture state move out of dom.ts entirely.
Main-editor migration (editor/grida-canvas-react/viewport/ui/*) is tracked separately. The host contract is the same; the only difference is the pick implementation (scene-cache R-tree instead of elementFromPoint+data-id).
