@grida/hud 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,52 @@ The `event/` layer is the testable core. It dispatches plain objects, returns pl
74
74
 
75
75
  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.
76
76
 
77
+ ## Two backends: render and hit-testing
78
+
79
+ The HUD has two backends, and they deliberately disagree.
80
+
81
+ 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.
82
+
83
+ ### Why they disagree
84
+
85
+ The two outputs optimise for different things:
86
+
87
+ - The renderer optimises for **legibility at any zoom** — small, crisp shapes that don't dominate the document.
88
+ - 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.
89
+
90
+ 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.
91
+
92
+ ### `OverlayElement` — the pairing primitive
93
+
94
+ `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.
95
+
96
+ The renderer never reads from `hit`. The hit-tester never reads from `render`. The asymmetry is the point.
97
+
98
+ ### Four families of overlay
99
+
100
+ The mental model the package operates under. Every overlay the HUD draws — or the next one anyone adds — falls into one of four shapes:
101
+
102
+ | Family | Visual | Hit region | Example |
103
+ | --------------- | ----------------- | ---------------------------------------- | ------------------------------------ |
104
+ | **Paired** | Drawn shape | Same shape, padded for reach | Corner resize knob, line endpoint |
105
+ | **Virtual** | None | Region the user can click but never sees | Rotation handles, edge-resize strips |
106
+ | **Decorative** | Drawn shape | None | Snap pips, measurement labels |
107
+ | **Body-region** | Selection outline | Inner region that triggers translate | Selected shape body |
108
+
109
+ 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.
110
+
111
+ ### Guidance for new affordances
112
+
113
+ 1. **Decide hit geometry first, visual second.** The user reaches for the hit region; the visual is a hint about where it is.
114
+ 2. **If an affordance is virtual, omit `render`.** Don't draw a stand-in just because the type allows it.
115
+ 3. **If the visual is smaller than the minimum comfortable touch target, pad the hit region.** Don't pad the visual to compensate.
116
+
117
+ ### Guidance for tests
118
+
119
+ 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.
120
+
121
+ 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).
122
+
77
123
  ## Public API
78
124
 
79
125
  ```ts
@@ -96,6 +142,7 @@ surface.setTransform(t); // camera (screen ↔ doc)
96
142
  surface.setSelection(ids); // read-only mirror from host
97
143
  surface.setStyle(partial);
98
144
  surface.setReadonly(v);
145
+ surface.setPixelGrid({ enabled, zoomThreshold, color?, steps? }); // or null to disable
99
146
  surface.dispose();
100
147
 
101
148
  // Input
@@ -156,8 +203,21 @@ type SurfaceGesture =
156
203
  | { kind: "pan"; dx: number; dy: number }
157
204
  | { kind: "marquee"; rect: Rect } // screen-space
158
205
  | { kind: "translate"; ids: NodeId[]; dx: number; dy: number }
159
- | { kind: "resize"; id: NodeId; anchor: ResizeDirection; rect: Rect }
160
- | { kind: "rotate"; id: NodeId; corner: RotationCorner; angle: number };
206
+ | {
207
+ kind: "resize";
208
+ ids: NodeId[];
209
+ direction: ResizeDirection;
210
+ initial_shape: SelectionShape;
211
+ current_shape: SelectionShape;
212
+ }
213
+ | {
214
+ kind: "rotate";
215
+ ids: NodeId[];
216
+ corner: RotationCorner;
217
+ anchor_angle: number;
218
+ current_angle: number;
219
+ }
220
+ | { kind: "endpoint"; id: NodeId; endpoint: "p1" | "p2" };
161
221
  ```
162
222
 
163
223
  ### Intents
@@ -171,12 +231,29 @@ type Intent =
171
231
  | { kind: "translate"; ids: NodeId[]; dx: number; dy: number; phase: Phase }
172
232
  | {
173
233
  kind: "resize";
174
- id: NodeId;
234
+ ids: NodeId[];
175
235
  anchor: ResizeDirection;
236
+ /** AABB of the new shape (for axis-aligned hosts). */
176
237
  rect: Rect;
238
+ /** Full new shape — `transformed` carries the matrix so rotated
239
+ * hosts can resize in the local frame. Optional for backward-compat. */
240
+ shape?: SelectionShape;
241
+ phase: Phase;
242
+ }
243
+ | { kind: "rotate"; ids: NodeId[]; angle: number; phase: Phase }
244
+ | {
245
+ kind: "marquee_select";
246
+ rect: Rect;
247
+ additive: boolean;
248
+ phase: Phase;
249
+ }
250
+ | {
251
+ kind: "set_endpoint";
252
+ id: NodeId;
253
+ endpoint: "p1" | "p2";
254
+ pos: [number, number];
177
255
  phase: Phase;
178
256
  }
179
- | { kind: "rotate"; id: NodeId; angle: number; phase: Phase }
180
257
  | { kind: "enter_content_edit"; id: NodeId }
181
258
  | { kind: "cancel_gesture" };
182
259
 
@@ -206,6 +283,28 @@ function Viewport() {
206
283
  }
207
284
  ```
208
285
 
286
+ ## Cursors
287
+
288
+ 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.
289
+
290
+ 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:
291
+
292
+ ```ts
293
+ import { cursors } from "@grida/hud/cursors";
294
+
295
+ surface.setCursorRenderer(cursors.defaultRenderer());
296
+ ```
297
+
298
+ **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`.
299
+
300
+ 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:
301
+
302
+ ```ts
303
+ import { cursors } from "@grida/hud/cursors";
304
+ const svg = cursors.templates.rotate(45); // angle in degrees
305
+ const url = cursors.svgDataUrl(svg);
306
+ ```
307
+
209
308
  ## Selection intent
210
309
 
211
310
  The full classification table — every named scenario at pointer-down, what
@@ -232,7 +331,13 @@ Skim the spec before changing the classifier or its dispatch.
232
331
  - Hit-test happens in two tiers on `pointer_down`:
233
332
  1. **UI layer (screen-space AABB)** — surface's own `HitRegions` registry, populated by chrome builder each frame. Resize handle? Rotation handle? Body region (translate)? Direct path; no host involvement.
234
333
  2. **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-id` for SVG-DOM hosts, scene-cache R-tree for cg).
235
- - `shapeOf(id)` returns a **doc-space** `SelectionShape` (`{ kind: "rect", rect }` for most nodes, `{ kind: "line", p1, p2 }` for vector lines). Surface converts to screen for handle placement and screen-space chrome.
334
+ - The UI-tier hit registry is built independently of the render path see [Two backends: render and hit-testing](#two-backends-render-and-hit-testing).
335
+ - `shapeOf(id)` returns a **doc-space** `SelectionShape`:
336
+ - `{ kind: "rect", rect }` — axis-aligned (most nodes)
337
+ - `{ kind: "line", p1, p2 }` — vector lines
338
+ - `{ kind: "transformed", local, matrix }` — anything with a non-identity 2×3 affine (rotation, skew, non-uniform scale, mirror). `local` is the artwork's local-frame AABB; `matrix` maps local → doc. Identity matrix here is byte-equivalent to `{ kind: "rect", rect: local }`.
339
+
340
+ See [Transformed selections](#transformed-selections) below for what the HUD does with the `transformed` variant.
236
341
 
237
342
  Handles are always drawn at a fixed screen-space size regardless of zoom. The primitive layer supports this via `HUDScreenRect` (see below).
238
343
 
@@ -249,6 +354,7 @@ Handles are always drawn at a fixed screen-space size regardless of zoom. The pr
249
354
 
250
355
  Layer order within a frame (back-to-front):
251
356
 
357
+ 0. Pixel grid (when enabled and `transform[0][0] > zoomThreshold`)
252
358
  1. Hover outline
253
359
  2. Selection outline
254
360
  3. Marquee rect
@@ -256,6 +362,32 @@ Layer order within a frame (back-to-front):
256
362
  5. Size meter pill
257
363
  6. Host-fed extras (always on top)
258
364
 
365
+ ## Transformed selections
366
+
367
+ 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.
368
+
369
+ **Render** uses lazy transforms — every rotated primitive carries an optional angle field; the canvas wraps the draw call in a `translate/rotate/restore`:
370
+
371
+ | Primitive | Field | Effect |
372
+ | --------------- | ------------------------------------ | ------------------------------------------------------ |
373
+ | `HUDScreenRect` | `angle?: number` (radians, CCW) | Rotates the rect around its screen-space center. |
374
+ | `HUDLine` | `labelAngle?: number` (radians, CCW) | Rotates the label pill around its screen-space center. |
375
+
376
+ **Hit-test** uses the same lazy transform via a new `HitShape` variant:
377
+
378
+ ```ts
379
+ type HitShape =
380
+ | { kind: "screen_rect_at_doc"; anchor_doc; width; height }
381
+ | { kind: "screen_aabb"; rect }
382
+ | { kind: "screen_obb"; rect; inverse_transform }; // ← new
383
+ ```
384
+
385
+ `screen_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.
386
+
387
+ 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.
388
+
389
+ **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.
390
+
259
391
  ## Primitives — what `HUDCanvas` can draw
260
392
 
261
393
  | Primitive | Coordinate space | Used by |
@@ -270,6 +402,8 @@ Layer order within a frame (back-to-front):
270
402
 
271
403
  `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.
272
404
 
405
+ 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.
406
+
273
407
  ## Module layout
274
408
 
275
409
  ```
@@ -294,10 +428,15 @@ packages/grida-canvas-hud/
294
428
  │ ├── intent.ts # Intent types + builders
295
429
  │ ├── transform.ts # screen ↔ doc helpers
296
430
  │ └── state.ts # SurfaceState — pure dispatch entry
297
- └── surface/ # the wired class
298
- ├── surface.ts # Surface class
299
- ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
300
- └── style.ts # HUDStyle defaults + merge
431
+ ├── surface/ # the wired class
432
+ ├── surface.ts # Surface class
433
+ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
434
+ └── style.ts # HUDStyle defaults + merge
435
+ └── cursors/ # opt-in subpath: @grida/hud/cursors
436
+ ├── index.ts # cursors.defaultRenderer(), templates, encoder
437
+ ├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
438
+ ├── templates.ts # parameterized SVG cursor templates
439
+ └── encode.ts # SVG → data: URL
301
440
  ```
302
441
 
303
442
  ## Naming conventions
@@ -311,19 +450,46 @@ packages/grida-canvas-hud/
311
450
 
312
451
  `packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas mock — the `event/` layer is pure.
313
452
 
314
- | File | Tests |
315
- | ----------------------- | ------------------------------------------------------------------------------------------------- |
316
- | `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
317
- | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
318
- | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
319
- | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
320
- | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
321
- | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
322
- | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview\*N → commit) |
323
- | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
453
+ | File | Tests |
454
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
455
+ | `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
456
+ | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
457
+ | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
458
+ | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
459
+ | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
460
+ | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
461
+ | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview\*N → commit) |
462
+ | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
463
+ | `chrome-transformed.test.ts` | `SelectionShape.transformed` end-to-end: outline, knob anchors, OBB hit-test exactness, identity ≡ rect equivalence |
464
+ | `cursors.test.ts` | `cursors.defaultRenderer` produces rotation-aware CSS values; tree-shake invariant (subpath isolated from main bundle) |
465
+ | `decision.test.ts` | one test per named scenario in the selection-intent classifier |
324
466
 
325
467
  Render output (visual canvas correctness) is verified in the browser, not unit-tested.
326
468
 
469
+ ## Extending the HUD
470
+
471
+ The HUD intentionally exposes no generic "register a painter" or "register a
472
+ layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real
473
+ needs, in this order of preference:
474
+
475
+ 1. **Named built-in chrome.** Things every Grida editor wants — pixel grid,
476
+ selection, snap guides, measurement — live inside this package as
477
+ first-class features with their own toggles (e.g. `setPixelGrid`,
478
+ `setStyle`, the chrome built from `SurfaceState`). New canonical chrome
479
+ lands here; open a PR against `@grida/hud`.
480
+
481
+ 2. **Host-fed `HUDDraw` extras.** Pass extra primitives into `surface.draw(extra)`
482
+ per frame. Best for transient, gesture-coupled overlays the host already
483
+ computes (measurement lines, custom snap visualizers). Drawn on top of named
484
+ chrome — they're foreground, not background.
485
+
486
+ 3. **DOM-level escape hatch.** The host owns the container element; the surface
487
+ only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay
488
+ (HTML toolbar, popover, debug widget) can splice their own DOM into the
489
+ container directly. Deliberate escape hatch — reach for it only when (1) and
490
+ (2) don't fit, and prefer pushing canonical needs into (1) over keeping them
491
+ here.
492
+
327
493
  ## Anti-goals
328
494
 
329
495
  - **Not a renderer.** `primitives/HUDCanvas` is intentionally minimal Canvas2D. Skia / WebGL backends are not in scope.
@@ -0,0 +1,64 @@
1
+ //#region event/cursor.d.ts
2
+ /** 8 cardinal/diagonal resize directions. */
3
+ type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
4
+ /** 4 corner positions for rotation handles. */
5
+ type RotationCorner = "nw" | "ne" | "se" | "sw";
6
+ /**
7
+ * Logical cursor icon names — the host maps these to CSS `cursor` values.
8
+ *
9
+ * `baseAngle` (radians) tilts the rendered arrow by an additional amount
10
+ * on top of the variant's canonical orientation. Set automatically by the
11
+ * surface for both variants:
12
+ *
13
+ * - **rotate**: `decideIdleCursor` reads the action's `initial_shape`
14
+ * and sets `baseAngle` to the matrix's screen-space rotation; the
15
+ * in-gesture `pointer_move` arm composes `initial_cursor_angle + delta`
16
+ * so the cursor tracks the live rotation, starting from the selection's
17
+ * pre-gesture orientation rather than 0.
18
+ * - **resize**: `decideIdleCursor` reads the action's `initial_shape`
19
+ * and sets `baseAngle` to the matrix's screen-space rotation, so the
20
+ * resize cursor on a rotated selection tilts to align with the rotated
21
+ * edge / corner.
22
+ *
23
+ * For `kind: "rect"` selections (the common case) `baseAngle` resolves
24
+ * to 0 and the cursor behaves identically to the pre-rotation builds.
25
+ */
26
+ type CursorIcon = "default" | "pointer" | "move" | "crosshair" | "grab" | "grabbing" | "text" | {
27
+ kind: "resize";
28
+ direction: ResizeDirection;
29
+ baseAngle?: number;
30
+ } | {
31
+ kind: "rotate";
32
+ corner: RotationCorner;
33
+ baseAngle?: number;
34
+ };
35
+ /**
36
+ * Pluggable cursor renderer.
37
+ *
38
+ * Maps a logical `CursorIcon` to a complete CSS `cursor:` value
39
+ * (e.g. a native keyword like `"crosshair"` or a data-URL form like
40
+ * `"url(data:...) 12 12, auto"`).
41
+ *
42
+ * The Surface owns one slot. Default = the built-in `cursorToCss` below.
43
+ * Hosts opt into the bundled SVG renderer via
44
+ * `surface.setCursorRenderer(cursors.defaultRenderer())` from
45
+ * `@grida/hud/cursors`, or supply their own function for full control.
46
+ */
47
+ type CursorRenderer = (icon: CursorIcon) => string;
48
+ /**
49
+ * Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
50
+ * for custom cursors.
51
+ */
52
+ declare function cursorToCss(c: CursorIcon): string;
53
+ /**
54
+ * Cursor-equality used to detect changes without re-emitting `cursorChanged`.
55
+ *
56
+ * Two rotate/resize icons compare equal only when both the
57
+ * discriminator (corner / direction) AND the bucketed `baseAngle` match.
58
+ * This is what lets the state machine call `setCursor` every frame
59
+ * during a rotate gesture and have `cursorChanged` fire only on real
60
+ * angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
61
+ */
62
+ declare function cursorEquals(a: CursorIcon, b: CursorIcon): boolean;
63
+ //#endregion
64
+ export { cursorEquals as a, RotationCorner as i, CursorRenderer as n, cursorToCss as o, ResizeDirection as r, CursorIcon as t };
@@ -0,0 +1,57 @@
1
+ //#region event/cursor.ts
2
+ /**
3
+ * Angle-comparison bucket — radians. 0.5° in radians. Two icons are
4
+ * considered equal when their `baseAngle`s round to the same bucket;
5
+ * this is what prevents unnecessary `cursorChanged` re-emit during a
6
+ * smooth rotate gesture, so the host repaints only on real motion.
7
+ *
8
+ * 0.5° is below human perception for cursor orientation, so the
9
+ * quantization is visually free.
10
+ */
11
+ const CURSOR_ANGLE_BUCKET_RAD = Math.PI / 360;
12
+ /**
13
+ * Quantize an angle (radians) to its `CURSOR_ANGLE_BUCKET_RAD` bucket.
14
+ * Used by `cursorEquals` to decide whether two cursors with the same
15
+ * variant differ visibly.
16
+ */
17
+ function angleBucket(rad) {
18
+ if (rad === void 0 || rad === 0) return 0;
19
+ return Math.round(rad / CURSOR_ANGLE_BUCKET_RAD);
20
+ }
21
+ /**
22
+ * Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
23
+ * for custom cursors.
24
+ */
25
+ function cursorToCss(c) {
26
+ if (typeof c === "string") switch (c) {
27
+ case "default": return "default";
28
+ case "pointer": return "pointer";
29
+ case "move": return "move";
30
+ case "crosshair": return "crosshair";
31
+ case "grab": return "grab";
32
+ case "grabbing": return "grabbing";
33
+ case "text": return "text";
34
+ }
35
+ if (c.kind === "resize") return `${c.direction}-resize`;
36
+ return "crosshair";
37
+ }
38
+ /**
39
+ * Cursor-equality used to detect changes without re-emitting `cursorChanged`.
40
+ *
41
+ * Two rotate/resize icons compare equal only when both the
42
+ * discriminator (corner / direction) AND the bucketed `baseAngle` match.
43
+ * This is what lets the state machine call `setCursor` every frame
44
+ * during a rotate gesture and have `cursorChanged` fire only on real
45
+ * angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
46
+ */
47
+ function cursorEquals(a, b) {
48
+ if (typeof a === "string" && typeof b === "string") return a === b;
49
+ if (typeof a !== "string" && typeof b !== "string") {
50
+ if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
51
+ if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
52
+ return false;
53
+ }
54
+ return false;
55
+ }
56
+ //#endregion
57
+ export { cursorToCss as i, angleBucket as n, cursorEquals as r, CURSOR_ANGLE_BUCKET_RAD as t };
@@ -0,0 +1,64 @@
1
+ //#region event/cursor.d.ts
2
+ /** 8 cardinal/diagonal resize directions. */
3
+ type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
4
+ /** 4 corner positions for rotation handles. */
5
+ type RotationCorner = "nw" | "ne" | "se" | "sw";
6
+ /**
7
+ * Logical cursor icon names — the host maps these to CSS `cursor` values.
8
+ *
9
+ * `baseAngle` (radians) tilts the rendered arrow by an additional amount
10
+ * on top of the variant's canonical orientation. Set automatically by the
11
+ * surface for both variants:
12
+ *
13
+ * - **rotate**: `decideIdleCursor` reads the action's `initial_shape`
14
+ * and sets `baseAngle` to the matrix's screen-space rotation; the
15
+ * in-gesture `pointer_move` arm composes `initial_cursor_angle + delta`
16
+ * so the cursor tracks the live rotation, starting from the selection's
17
+ * pre-gesture orientation rather than 0.
18
+ * - **resize**: `decideIdleCursor` reads the action's `initial_shape`
19
+ * and sets `baseAngle` to the matrix's screen-space rotation, so the
20
+ * resize cursor on a rotated selection tilts to align with the rotated
21
+ * edge / corner.
22
+ *
23
+ * For `kind: "rect"` selections (the common case) `baseAngle` resolves
24
+ * to 0 and the cursor behaves identically to the pre-rotation builds.
25
+ */
26
+ type CursorIcon = "default" | "pointer" | "move" | "crosshair" | "grab" | "grabbing" | "text" | {
27
+ kind: "resize";
28
+ direction: ResizeDirection;
29
+ baseAngle?: number;
30
+ } | {
31
+ kind: "rotate";
32
+ corner: RotationCorner;
33
+ baseAngle?: number;
34
+ };
35
+ /**
36
+ * Pluggable cursor renderer.
37
+ *
38
+ * Maps a logical `CursorIcon` to a complete CSS `cursor:` value
39
+ * (e.g. a native keyword like `"crosshair"` or a data-URL form like
40
+ * `"url(data:...) 12 12, auto"`).
41
+ *
42
+ * The Surface owns one slot. Default = the built-in `cursorToCss` below.
43
+ * Hosts opt into the bundled SVG renderer via
44
+ * `surface.setCursorRenderer(cursors.defaultRenderer())` from
45
+ * `@grida/hud/cursors`, or supply their own function for full control.
46
+ */
47
+ type CursorRenderer = (icon: CursorIcon) => string;
48
+ /**
49
+ * Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
50
+ * for custom cursors.
51
+ */
52
+ declare function cursorToCss(c: CursorIcon): string;
53
+ /**
54
+ * Cursor-equality used to detect changes without re-emitting `cursorChanged`.
55
+ *
56
+ * Two rotate/resize icons compare equal only when both the
57
+ * discriminator (corner / direction) AND the bucketed `baseAngle` match.
58
+ * This is what lets the state machine call `setCursor` every frame
59
+ * during a rotate gesture and have `cursorChanged` fire only on real
60
+ * angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
61
+ */
62
+ declare function cursorEquals(a: CursorIcon, b: CursorIcon): boolean;
63
+ //#endregion
64
+ export { cursorEquals as a, RotationCorner as i, CursorRenderer as n, cursorToCss as o, ResizeDirection as r, CursorIcon as t };
@@ -0,0 +1,80 @@
1
+ //#region event/cursor.ts
2
+ /**
3
+ * Angle-comparison bucket — radians. 0.5° in radians. Two icons are
4
+ * considered equal when their `baseAngle`s round to the same bucket;
5
+ * this is what prevents unnecessary `cursorChanged` re-emit during a
6
+ * smooth rotate gesture, so the host repaints only on real motion.
7
+ *
8
+ * 0.5° is below human perception for cursor orientation, so the
9
+ * quantization is visually free.
10
+ */
11
+ const CURSOR_ANGLE_BUCKET_RAD = Math.PI / 360;
12
+ /**
13
+ * Quantize an angle (radians) to its `CURSOR_ANGLE_BUCKET_RAD` bucket.
14
+ * Used by `cursorEquals` to decide whether two cursors with the same
15
+ * variant differ visibly.
16
+ */
17
+ function angleBucket(rad) {
18
+ if (rad === void 0 || rad === 0) return 0;
19
+ return Math.round(rad / CURSOR_ANGLE_BUCKET_RAD);
20
+ }
21
+ /**
22
+ * Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
23
+ * for custom cursors.
24
+ */
25
+ function cursorToCss(c) {
26
+ if (typeof c === "string") switch (c) {
27
+ case "default": return "default";
28
+ case "pointer": return "pointer";
29
+ case "move": return "move";
30
+ case "crosshair": return "crosshair";
31
+ case "grab": return "grab";
32
+ case "grabbing": return "grabbing";
33
+ case "text": return "text";
34
+ }
35
+ if (c.kind === "resize") return `${c.direction}-resize`;
36
+ return "crosshair";
37
+ }
38
+ /**
39
+ * Cursor-equality used to detect changes without re-emitting `cursorChanged`.
40
+ *
41
+ * Two rotate/resize icons compare equal only when both the
42
+ * discriminator (corner / direction) AND the bucketed `baseAngle` match.
43
+ * This is what lets the state machine call `setCursor` every frame
44
+ * during a rotate gesture and have `cursorChanged` fire only on real
45
+ * angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
46
+ */
47
+ function cursorEquals(a, b) {
48
+ if (typeof a === "string" && typeof b === "string") return a === b;
49
+ if (typeof a !== "string" && typeof b !== "string") {
50
+ if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
51
+ if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
52
+ return false;
53
+ }
54
+ return false;
55
+ }
56
+ //#endregion
57
+ Object.defineProperty(exports, "CURSOR_ANGLE_BUCKET_RAD", {
58
+ enumerable: true,
59
+ get: function() {
60
+ return CURSOR_ANGLE_BUCKET_RAD;
61
+ }
62
+ });
63
+ Object.defineProperty(exports, "angleBucket", {
64
+ enumerable: true,
65
+ get: function() {
66
+ return angleBucket;
67
+ }
68
+ });
69
+ Object.defineProperty(exports, "cursorEquals", {
70
+ enumerable: true,
71
+ get: function() {
72
+ return cursorEquals;
73
+ }
74
+ });
75
+ Object.defineProperty(exports, "cursorToCss", {
76
+ enumerable: true,
77
+ get: function() {
78
+ return cursorToCss;
79
+ }
80
+ });
@@ -0,0 +1,98 @@
1
+ import { n as CursorRenderer } from "../cursor-BFGUuD2M.mjs";
2
+
3
+ //#region cursors/renderer.d.ts
4
+ /**
5
+ * Build the default cursor renderer.
6
+ *
7
+ * Stateless — every call regenerates the data URL from scratch. This is
8
+ * cheap (`template_*` is a string-concat, `btoa` of ~600 B is
9
+ * sub-millisecond) AND the upstream `SurfaceState.setCursor` already
10
+ * gates re-emit via `cursorEquals`, which buckets `baseAngle` to
11
+ * `CURSOR_ANGLE_BUCKET_RAD`. Net result: this function is invoked at
12
+ * most once per real bucket change — caching here would be redundant
13
+ * and, sized wrong (the previous 64-entry LRU thrashed after ~32° of
14
+ * drag), actively harmful.
15
+ *
16
+ * Hosts install via `surface.setCursorRenderer(...)`.
17
+ *
18
+ * @example
19
+ * import { cursors } from "@grida/hud/cursors";
20
+ * surface.setCursorRenderer(cursors.defaultRenderer());
21
+ */
22
+ declare function defaultRenderer(): CursorRenderer;
23
+ //#endregion
24
+ //#region cursors/encode.d.ts
25
+ /**
26
+ * SVG → `data:` URL encoding for CSS `cursor:` values.
27
+ *
28
+ * Uses base64 (`btoa`) for two reasons:
29
+ * - Cross-browser support is universal; URL-encoded SVG has historically had
30
+ * parser inconsistencies on `#` and `<`.
31
+ * - It's what the main editor's `cursor-data.ts` uses, so cursor visuals
32
+ * compare byte-identical when we A/B during the eventual migration.
33
+ *
34
+ * Browser-only — `btoa` is part of the WHATWG HTML spec, available on
35
+ * Worker / Window. The package's `platform: "neutral"` tsdown config means
36
+ * we don't pull a Node polyfill; consumers using SSR must avoid evaluating
37
+ * the renderer on the server (the Surface itself is client-side).
38
+ */
39
+ /** Encode an SVG string as `data:image/svg+xml;base64,...`. */
40
+ declare function svgDataUrl(svg: string): string;
41
+ //#endregion
42
+ //#region cursors/templates.d.ts
43
+ /**
44
+ * SVG cursor templates — pure string templates parameterized by angle.
45
+ *
46
+ * Two families:
47
+ *
48
+ * 1. **Rotate arrow** — a curved double-arrow. Built from one template
49
+ * (`template_rotate`) rotated by `angle_deg` around the SVG's
50
+ * hotspot. Per-corner orientation comes from caller-supplied
51
+ * initial offsets (NW: −45°, NE: +45°, SW: −135°, SE: +135°), in
52
+ * Phase A. In Phase B, the host's selection rotation is added on
53
+ * top of that.
54
+ *
55
+ * 2. **Resize arrow** — a straight double-arrow, also built from one
56
+ * template (`template_resize`) rotated per cardinal direction. The
57
+ * 8 standard directions (N/NE/E/SE/S/SW/W/NW) map to multiples of
58
+ * 45° from the canonical horizontal arrow.
59
+ *
60
+ * **Fixed Grida palette** — black fill, white stroke. Cursors are not
61
+ * color-themed; hosts wanting custom colors install a custom
62
+ * `CursorRenderer` (see `renderer.ts`).
63
+ *
64
+ * **Hotspots** are documented per template — the click point inside
65
+ * the SVG that the OS aligns with the actual pointer position. Hotspots
66
+ * are fixed (SVG center) because the SVG rotates around the same point;
67
+ * the cursor stays anchored to the pointer regardless of angle.
68
+ */
69
+ /** Rotate cursor SVG. `angle_deg` rotates the arrow around (13, 12). */
70
+ declare function template_rotate(angle_deg: number): string;
71
+ /**
72
+ * Resize cursor SVG. `angle_deg` rotates the arrow around (16, 16).
73
+ *
74
+ * For the canonical horizontal arrow pass `0`. For the 8 standard
75
+ * cardinal directions, see `DIRECTION_ANGLE_DEG` in `renderer.ts`.
76
+ */
77
+ declare function template_resize(angle_deg: number): string;
78
+ //#endregion
79
+ //#region cursors/index.d.ts
80
+ /**
81
+ * Convenience namespace export. All cursor utilities are also available
82
+ * as named imports — but `cursors.defaultRenderer()` reads more cleanly
83
+ * at host call sites, mirroring the main editor's `cursors.*` style.
84
+ */
85
+ declare const cursors: {
86
+ readonly defaultRenderer: typeof defaultRenderer;
87
+ readonly svgDataUrl: typeof svgDataUrl;
88
+ readonly templates: {
89
+ readonly rotate: typeof template_rotate;
90
+ readonly resize: typeof template_resize;
91
+ };
92
+ readonly hotspots: {
93
+ readonly rotate: readonly [number, number];
94
+ readonly resize: readonly [number, number];
95
+ };
96
+ };
97
+ //#endregion
98
+ export { type CursorRenderer, cursors, defaultRenderer, svgDataUrl };