From 29b68c770fc080dda149828e5663c5b94e3975ac Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 6 Apr 2026 17:11:41 +0900 Subject: [PATCH 1/3] feat(canvas): add headless Editor for non-browser testing Abstract browser dependencies behind injectable interfaces so the Editor can be fully instantiated and exercised in pure Node.js/Vitest: - Add editor.api.IViewportApi, editor.api.events.{IModifiers, IPointerEvent, IKeyboardEvent, IFocusEvent} as platform-neutral event IRs (browser DOM events satisfy them via duck typing) - Add HeadlessViewportApi, NoopPropertiesQueryProvider backends - Add Editor.createHeadless() static factory - Make Editor constructor accept viewportApi/properties_query injection - Fix writeClipboardMedia to route through ui.clipboard with fallback - Extract shared test utilities into __tests__/utils/ - Add 8 headless test files (gate + behavioral: lifecycle, selection, undo-redo, camera, subscription, surface-tools, node-properties) - Rewrite history, select-readonly, move-tray tests to use headless Editor instead of raw reducer calls with copy-pasted stubs --- .../__tests__/headless-gate.test.ts | 78 ++++ .../__tests__/headless/camera.test.ts | 110 +++++ .../__tests__/headless/lifecycle.test.ts | 62 +++ .../headless/node-properties.test.ts | 108 +++++ .../__tests__/headless/selection.test.ts | 52 +++ .../__tests__/headless/subscription.test.ts | 60 +++ .../__tests__/headless/surface-tools.test.ts | 114 +++++ .../__tests__/headless/undo-redo.test.ts | 89 ++++ .../__tests__/utils/create-headless-editor.ts | 38 ++ .../grida-canvas/__tests__/utils/factories.ts | 150 +++++++ .../grida-canvas/__tests__/utils/fixtures.ts | 74 ++++ editor/grida-canvas/__tests__/utils/index.ts | 20 + editor/grida-canvas/__tests__/utils/stubs.ts | 40 ++ editor/grida-canvas/backends/dom.ts | 3 +- editor/grida-canvas/backends/headless.ts | 38 ++ editor/grida-canvas/backends/index.ts | 1 + editor/grida-canvas/backends/noop.ts | 20 +- editor/grida-canvas/commands/text-edit.ts | 3 +- editor/grida-canvas/editor.i.ts | 93 +++- editor/grida-canvas/editor.ts | 153 +++++-- .../reducers/__tests__/history.test.ts | 399 +++++------------- .../__tests__/select-readonly.test.ts | 244 +++-------- .../methods/__tests__/move-tray.test.ts | 216 +++------- 23 files changed, 1479 insertions(+), 686 deletions(-) create mode 100644 editor/grida-canvas/__tests__/headless-gate.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/camera.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/lifecycle.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/node-properties.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/selection.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/subscription.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/surface-tools.test.ts create mode 100644 editor/grida-canvas/__tests__/headless/undo-redo.test.ts create mode 100644 editor/grida-canvas/__tests__/utils/create-headless-editor.ts create mode 100644 editor/grida-canvas/__tests__/utils/factories.ts create mode 100644 editor/grida-canvas/__tests__/utils/fixtures.ts create mode 100644 editor/grida-canvas/__tests__/utils/index.ts create mode 100644 editor/grida-canvas/__tests__/utils/stubs.ts create mode 100644 editor/grida-canvas/backends/headless.ts diff --git a/editor/grida-canvas/__tests__/headless-gate.test.ts b/editor/grida-canvas/__tests__/headless-gate.test.ts new file mode 100644 index 0000000000..d86b4e05f6 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless-gate.test.ts @@ -0,0 +1,78 @@ +/** + * Gate 1: Instantiation Gate + * + * Proves that Editor can be constructed and disposed in pure Node.js + * without any browser globals (window, document, navigator, etc.). + * + * @vitest-environment node + */ +import { describe, test, expect } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Gate 0: No browser globals present", () => { + test("window is NOT defined (proves we are in pure Node.js, not jsdom)", () => { + expect(typeof window).toBe("undefined"); + }); + + test("document is NOT defined", () => { + expect(typeof document).toBe("undefined"); + }); + + test("HTMLElement is NOT defined", () => { + expect(typeof HTMLElement).toBe("undefined"); + }); +}); + +describe("Gate 1: Headless Instantiation", () => { + test("Editor.createHeadless() succeeds", () => { + const ed = createHeadlessEditor(); + expect(ed).toBeInstanceOf(Editor); + ed.dispose(); + }); + + test("editor.state is defined after construction", () => { + const ed = createHeadlessEditor(); + expect(ed.state).toBeDefined(); + expect(ed.state.document).toBeDefined(); + expect(ed.state.document.nodes).toBeDefined(); + ed.dispose(); + }); + + test("editor.doc (EditorDocumentStore) is available", () => { + const ed = createHeadlessEditor(); + expect(ed.doc).toBeDefined(); + expect(ed.doc.state).toBe(ed.state); + ed.dispose(); + }); + + test("editor.surface (EditorSurface) is available", () => { + const ed = createHeadlessEditor(); + expect(ed.surface).toBeDefined(); + ed.dispose(); + }); + + test("editor.camera (Camera) is available", () => { + const ed = createHeadlessEditor(); + expect(ed.camera).toBeDefined(); + expect(ed.camera.transform).toBeDefined(); + ed.dispose(); + }); + + test("editor.commands alias points to doc", () => { + const ed = createHeadlessEditor(); + expect(ed.commands).toBe(ed.doc); + ed.dispose(); + }); + + test("dispose does not throw", () => { + const ed = createHeadlessEditor(); + expect(() => ed.dispose()).not.toThrow(); + }); + + test("static createHeadless with custom viewport", () => { + const ed = createHeadlessEditor({ viewport: { width: 800, height: 600 } }); + expect(ed.camera.viewport.size).toEqual({ width: 800, height: 600 }); + ed.dispose(); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/camera.test.ts b/editor/grida-canvas/__tests__/headless/camera.test.ts new file mode 100644 index 0000000000..4586c8c884 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/camera.test.ts @@ -0,0 +1,110 @@ +/** + * Gate 3: Behavioral Correctness - Camera + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; +import cmath from "@grida/cmath"; + +describe("Camera (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("initial transform is identity", () => { + const t = ed.camera.transform; + expect(t[0][0]).toBe(1); // scaleX + expect(t[1][1]).toBe(1); // scaleY + expect(t[0][2]).toBe(0); // translateX + expect(t[1][2]).toBe(0); // translateY + }); + + test("set transform updates state", () => { + const next: cmath.Transform = [ + [2, 0, 100], + [0, 2, 200], + ]; + ed.camera.transform = next; + expect(ed.state.transform).toEqual(next); + }); + + test("pan shifts translate", () => { + ed.camera.pan([50, 100]); + expect(ed.state.transform[0][2]).toBe(50); + expect(ed.state.transform[1][2]).toBe(100); + }); + + test("zoom modifies scale", () => { + const before = ed.camera.transform[0][0]; + ed.camera.zoom(0.5, [960, 540]); // zoom in 50% at center + const after = ed.camera.transform[0][0]; + expect(after).toBeGreaterThan(before); + }); + + test("zoomIn increases scale", () => { + const before = ed.camera.transform[0][0]; + ed.camera.zoomIn(); + expect(ed.camera.transform[0][0]).toBeGreaterThan(before); + }); + + test("zoomOut decreases scale", () => { + const before = ed.camera.transform[0][0]; + ed.camera.zoomOut(); + expect(ed.camera.transform[0][0]).toBeLessThan(before); + }); + + test("viewport size is accessible", () => { + const size = ed.camera.viewport.size; + expect(size.width).toBe(1920); + expect(size.height).toBe(1080); + }); + + test("viewport offset is [0,0] in headless mode", () => { + expect(ed.camera.viewport.offset).toEqual([0, 0]); + }); + + test("clientPointToCanvasPoint with identity transform", () => { + const canvas = ed.camera.clientPointToCanvasPoint([100, 200]); + expect(canvas[0]).toBe(100); + expect(canvas[1]).toBe(200); + }); + + test("canvasPointToClientPoint with identity transform", () => { + const client = ed.camera.canvasPointToClientPoint([100, 200]); + expect(client[0]).toBe(100); + expect(client[1]).toBe(200); + }); + + test("client<->canvas roundtrip", () => { + // Set a non-trivial transform + ed.camera.transform = [ + [2, 0, 50], + [0, 2, 75], + ]; + const original: cmath.Vector2 = [300, 400]; + const canvas = ed.camera.clientPointToCanvasPoint(original); + const back = ed.camera.canvasPointToClientPoint(canvas); + expect(back[0]).toBeCloseTo(original[0], 5); + expect(back[1]).toBeCloseTo(original[1], 5); + }); + + test("pointerEventToViewportPoint extracts correct coords", () => { + const point = ed.camera.pointerEventToViewportPoint({ + clientX: 500, + clientY: 300, + button: 0, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + }); + expect(point.x).toBe(500); + expect(point.y).toBe(300); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/lifecycle.test.ts b/editor/grida-canvas/__tests__/headless/lifecycle.test.ts new file mode 100644 index 0000000000..6d04d2d102 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/lifecycle.test.ts @@ -0,0 +1,62 @@ +/** + * Gate 3: Behavioral Correctness - Editor Lifecycle + * + * Tests the full create -> dispatch -> query -> dispose lifecycle. + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Editor Lifecycle (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("initial state has the correct document shape", () => { + expect(ed.state.document.scenes_ref).toEqual(["scene"]); + expect(ed.state.document.nodes["scene"]).toBeDefined(); + expect(ed.state.document.nodes["rect-0"]).toBeDefined(); + expect(ed.state.document.nodes["rect-1"]).toBeDefined(); + }); + + test("initial state has empty selection", () => { + expect(ed.state.selection).toEqual([]); + }); + + test("initial state is editable", () => { + expect(ed.state.editable).toBe(true); + }); + + test("getSnapshot returns same reference as state", () => { + const snap = ed.getSnapshot(); + expect(snap).toBe(ed.state); + }); + + test("getJson returns a serializable object", () => { + const json = ed.getJson(); + expect(json).toBeDefined(); + // Must be JSON-serializable (no circular refs, no undefined) + expect(() => JSON.stringify(json)).not.toThrow(); + }); + + test("getDocumentJson returns only the document", () => { + const json = ed.getDocumentJson() as any; + expect(json.scenes_ref).toBeDefined(); + expect(json.nodes).toBeDefined(); + }); + + test("tree() returns an ascii tree containing node names", () => { + const tree = ed.tree(); + expect(typeof tree).toBe("string"); + // The tree should mention the scene and child node names + expect(tree).toContain("scene"); + expect(tree).toContain("rect-0"); + expect(tree).toContain("rect-1"); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/node-properties.test.ts b/editor/grida-canvas/__tests__/headless/node-properties.test.ts new file mode 100644 index 0000000000..d742276857 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/node-properties.test.ts @@ -0,0 +1,108 @@ +/** + * Gate 3: Behavioral Correctness - Node Property Changes + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; +import type grida from "@grida/schema"; +import color from "@grida/color"; + +describe("Node Properties (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("change node name via dispatch", () => { + ed.doc.dispatch({ + type: "node/change/*", + node_id: "rect-0", + name: "My Rectangle", + }); + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.name).toBe("My Rectangle"); + }); + + test("toggle node active", () => { + const before = (ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode).active; + ed.doc.toggleNodeActive("rect-0"); + const after = (ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode).active; + expect(after).toBe(!before); + }); + + test("toggle node locked", () => { + const before = (ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode).locked; + ed.doc.toggleNodeLocked("rect-0"); + const after = (ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode).locked; + expect(after).toBe(!before); + }); + + test("change opacity via dispatch", () => { + ed.doc.dispatch({ + type: "node/change/*", + node_id: "rect-0", + opacity: 0.5, + }); + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.opacity).toBe(0.5); + }); + + test("change rotation via dispatch", () => { + ed.doc.dispatch({ + type: "node/change/*", + node_id: "rect-0", + rotation: 45, + }); + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.rotation).toBe(45); + }); + + test("change fills applies the paint value", () => { + const paint = { + type: "solid" as const, + color: color.colorformats.RGBA32F.BLACK, + active: true, + }; + ed.doc.changeNodePropertyFills(["rect-0"], [paint]); + const node = ed.state.document.nodes["rect-0"] as any; + // Fill can be stored as `fill` (single) or `fill_paints` (array) + const fill = node.fill ?? node.fill_paints?.[0]; + expect(fill).toBeDefined(); + expect(fill.type).toBe("solid"); + expect(fill.active).toBe(true); + // TODO: Assert the actual color value once the fill storage format + // is stabilized (single `fill` vs `fill_paints` array). + }); + + test("change node size via dispatch", () => { + ed.doc.dispatch({ + type: "node/change/*", + node_id: "rect-0", + layout_target_width: 300, + }); + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.layout_target_width).toBe(300); + }); + + test("NodeProxy get/set roundtrip", () => { + const proxy = ed.doc.getNodeById("rect-0"); + expect(proxy.id).toBe("rect-0"); + + proxy.name = "Renamed"; + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.name).toBe("Renamed"); + expect(proxy.name).toBe("Renamed"); + }); + + test("NodeProxy opacity set", () => { + const proxy = ed.doc.getNodeById("rect-0"); + proxy.opacity = 0.3; + const node = ed.state.document.nodes["rect-0"] as grida.program.nodes.UnknownNode; + expect(node.opacity).toBe(0.3); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/selection.test.ts b/editor/grida-canvas/__tests__/headless/selection.test.ts new file mode 100644 index 0000000000..4554675467 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/selection.test.ts @@ -0,0 +1,52 @@ +/** + * Gate 3: Behavioral Correctness - Selection + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Selection (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("select single node", () => { + ed.doc.select(["rect-0"]); + expect(ed.state.selection).toEqual(["rect-0"]); + }); + + test("select multiple nodes", () => { + ed.doc.select(["rect-0", "rect-1"]); + expect(ed.state.selection).toEqual(["rect-0", "rect-1"]); + }); + + test("blur clears selection", () => { + ed.doc.select(["rect-0"]); + ed.doc.blur(); + expect(ed.state.selection).toEqual([]); + }); + + test("select with reset mode replaces selection", () => { + ed.doc.select(["rect-0"]); + ed.doc.select(["rect-1"], "reset"); + expect(ed.state.selection).toEqual(["rect-1"]); + }); + + test("select with add mode appends", () => { + ed.doc.select(["rect-0"]); + ed.doc.select(["rect-1"], "add"); + expect(ed.state.selection).toContain("rect-0"); + expect(ed.state.selection).toContain("rect-1"); + }); + + test("select non-existent node throws", () => { + // The reducer validates node existence and throws if not found + expect(() => ed.doc.select(["does-not-exist"])).toThrow(); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/subscription.test.ts b/editor/grida-canvas/__tests__/headless/subscription.test.ts new file mode 100644 index 0000000000..2968d541e5 --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/subscription.test.ts @@ -0,0 +1,60 @@ +/** + * Gate 3: Behavioral Correctness - Subscription System + */ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Subscription (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("subscribe fires on dispatch", () => { + const spy = vi.fn(); + ed.subscribe(spy); + ed.doc.select(["rect-0"]); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("subscribe fires on every dispatch", () => { + const spy = vi.fn(); + ed.subscribe(spy); + ed.doc.select(["rect-0"]); + ed.doc.blur(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + test("unsubscribe stops notifications", () => { + const spy = vi.fn(); + const unsub = ed.subscribe(spy); + ed.doc.select(["rect-0"]); + expect(spy).toHaveBeenCalledTimes(1); + unsub(); + ed.doc.blur(); + expect(spy).toHaveBeenCalledTimes(1); // still 1 + }); + + test("doc.subscribeWithSelector only fires on selected state change", () => { + const spy = vi.fn(); + ed.doc.subscribeWithSelector( + (state) => state.selection, + (_doc, selection) => spy(selection), + (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) + ); + + // This should fire - selection changes + ed.doc.select(["rect-0"]); + expect(spy).toHaveBeenCalledTimes(1); + + // Dispatching a camera transform should NOT fire the selection subscriber + ed.doc.dispatch({ type: "transform", transform: [[2, 0, 0], [0, 2, 0]], sync: false }); + expect(spy).toHaveBeenCalledTimes(1); // still 1 + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/surface-tools.test.ts b/editor/grida-canvas/__tests__/headless/surface-tools.test.ts new file mode 100644 index 0000000000..812e4a262e --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/surface-tools.test.ts @@ -0,0 +1,114 @@ +/** + * Gate 3: Behavioral Correctness - Surface Tools & Modes + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Surface Tools (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("initial tool is cursor", () => { + expect(ed.state.tool.type).toBe("cursor"); + }); + + test("set tool to rectangle", () => { + ed.surface.surfaceSetTool({ type: "insert", node: "rectangle" }); + expect(ed.state.tool.type).toBe("insert"); + }); + + test("set tool back to cursor", () => { + ed.surface.surfaceSetTool({ type: "insert", node: "rectangle" }); + ed.surface.surfaceSetTool({ type: "cursor" }); + expect(ed.state.tool.type).toBe("cursor"); + }); + + test("a11yEscape resets tool to cursor", () => { + ed.surface.surfaceSetTool({ type: "insert", node: "rectangle" }); + ed.surface.a11yEscape(); + expect(ed.state.tool.type).toBe("cursor"); + }); + + test("a11yEscape on cursor with selection clears selection", () => { + ed.doc.select(["rect-0"]); + expect(ed.state.selection).toEqual(["rect-0"]); + ed.surface.a11yEscape(); + expect(ed.state.selection).toEqual([]); + }); + + test("pixel grid toggle", () => { + // Default state: check initial and toggle + const initial = ed.state.pixelgrid; + const result = ed.surface.surfaceTogglePixelGrid(); + expect(result).not.toBe(initial); + expect(ed.state.pixelgrid).toBe(result); + + const result2 = ed.surface.surfaceTogglePixelGrid(); + expect(result2).toBe(initial); + }); + + test("ruler toggle", () => { + const initial = ed.state.ruler; + const result = ed.surface.surfaceToggleRuler(); + expect(result).not.toBe(initial); + expect(ed.state.ruler).toBe(result); + }); + + test("outline mode toggle", () => { + expect(ed.state.outline_mode).toBe("off"); + const result = ed.surface.surfaceToggleOutlineMode(); + expect(result).toBe("on"); + expect(ed.state.outline_mode).toBe("on"); + }); + + test("onblur resets modifier state", () => { + ed.surface.surfaceSetTool({ type: "insert", node: "rectangle" }); + ed.surface.onblur(); + expect(ed.state.tool.type).toBe("cursor"); + }); + + test("surface measurement config updates state", () => { + ed.surface.surfaceConfigureMeasurement("on"); + expect(ed.state.surface_measurement_targeting).toBe("on"); + ed.surface.surfaceConfigureMeasurement("off"); + expect(ed.state.surface_measurement_targeting).toBe("off"); + }); + + test("modifier configs update state", () => { + ed.surface.surfaceConfigureTranslateWithCloneModifier("on"); + expect(ed.state.gesture_modifiers.translate_with_clone).toBe("on"); + + ed.surface.surfaceConfigureTranslateWithAxisLockModifier("on"); + expect(ed.state.gesture_modifiers.tarnslate_with_axis_lock).toBe("on"); + + ed.surface.surfaceConfigureTransformWithCenterOriginModifier("on"); + expect(ed.state.gesture_modifiers.transform_with_center_origin).toBe("on"); + + ed.surface.surfaceConfigureTransformWithPreserveAspectRatioModifier("on"); + expect(ed.state.gesture_modifiers.transform_with_preserve_aspect_ratio).toBe( + "on" + ); + + ed.surface.surfaceConfigureRotateWithQuantizeModifier(15); + expect(ed.state.gesture_modifiers.rotate_with_quantize).toBe(15); + + ed.surface.surfaceConfigurePaddingWithMirroringModifier("on"); + expect(ed.state.gesture_modifiers.padding_with_axis_mirroring).toBe("on"); + }); + + // TODO: Add state assertions for surfaceConfigureSurfaceRaycastTargeting + // once the hit-testing config shape on IEditorState is confirmed stable. + test("raycast targeting config does not throw", () => { + expect(() => + ed.surface.surfaceConfigureSurfaceRaycastTargeting({ target: "auto" }) + ).not.toThrow(); + }); +}); diff --git a/editor/grida-canvas/__tests__/headless/undo-redo.test.ts b/editor/grida-canvas/__tests__/headless/undo-redo.test.ts new file mode 100644 index 0000000000..05315e293b --- /dev/null +++ b/editor/grida-canvas/__tests__/headless/undo-redo.test.ts @@ -0,0 +1,89 @@ +/** + * Gate 3: Behavioral Correctness - Undo/Redo + */ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; + +describe("Undo/Redo (headless)", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor(); + }); + + afterEach(() => { + ed.dispose(); + }); + + test("undo after select restores previous selection", () => { + expect(ed.state.selection).toEqual([]); + ed.doc.select(["rect-0"]); + expect(ed.state.selection).toEqual(["rect-0"]); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); + }); + + test("redo after undo restores the undone state", () => { + ed.doc.select(["rect-0"]); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); + ed.doc.redo(); + expect(ed.state.selection).toEqual(["rect-0"]); + }); + + test("undo on empty history is no-op", () => { + const before = ed.state; + ed.doc.undo(); + expect(ed.state.selection).toEqual(before.selection); + }); + + test("redo on empty future is no-op", () => { + const before = ed.state; + ed.doc.redo(); + expect(ed.state.selection).toEqual(before.selection); + }); + + test("multiple undo steps outside merge window", () => { + const nowSpy = vi.spyOn(Date, "now"); + + // First action at t=1000 + nowSpy.mockReturnValueOnce(1000); + ed.doc.select(["rect-0"]); + expect(ed.state.selection).toEqual(["rect-0"]); + + // Second action at t=2000 — well outside the 300ms merge window + nowSpy.mockReturnValueOnce(2000); + nowSpy.mockReturnValueOnce(2000); + ed.doc.select(["rect-1"]); + expect(ed.state.selection).toEqual(["rect-1"]); + + // Should be two separate history entries + expect(ed.doc.historySnapshot.past).toHaveLength(2); + + // Undo once — restores to rect-0 + ed.doc.undo(); + expect(ed.state.selection).toEqual(["rect-0"]); + + // Undo again — restores to empty + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); + + // Redo twice — back to final state + ed.doc.redo(); + expect(ed.state.selection).toEqual(["rect-0"]); + ed.doc.redo(); + expect(ed.state.selection).toEqual(["rect-1"]); + + nowSpy.mockRestore(); + }); + + test("undo after delete restores the deleted node", () => { + ed.doc.select(["rect-0"]); + ed.doc.delete(["rect-0"]); + expect(ed.state.document.nodes["rect-0"]).toBeUndefined(); + + ed.doc.undo(); + expect(ed.state.document.nodes["rect-0"]).toBeDefined(); + }); +}); diff --git a/editor/grida-canvas/__tests__/utils/create-headless-editor.ts b/editor/grida-canvas/__tests__/utils/create-headless-editor.ts new file mode 100644 index 0000000000..3993a18e90 --- /dev/null +++ b/editor/grida-canvas/__tests__/utils/create-headless-editor.ts @@ -0,0 +1,38 @@ +/** + * Convenience wrapper around Editor.createHeadless() for tests. + */ +import { Editor } from "@/grida-canvas/editor"; +import type { editor } from "@/grida-canvas"; +import { MINIMAL_DOCUMENT, createDocumentWithRects } from "./fixtures"; + +export interface HeadlessEditorOptions { + document?: editor.state.IEditorStateInit["document"]; + editable?: boolean; + viewport?: { width: number; height: number }; +} + +/** + * Create a headless editor for testing. + * + * @example + * ```ts + * const ed = createHeadlessEditor(); + * ed.doc.select(["rect-0"]); + * expect(ed.state.selection).toEqual(["rect-0"]); + * ed.dispose(); + * ``` + */ +export function createHeadlessEditor(opts?: HeadlessEditorOptions): Editor { + const document = opts?.document ?? createDocumentWithRects(2); + + return Editor.createHeadless( + { + document, + editable: opts?.editable ?? true, + debug: false, + }, + { + viewport: opts?.viewport, + } + ); +} diff --git a/editor/grida-canvas/__tests__/utils/factories.ts b/editor/grida-canvas/__tests__/utils/factories.ts new file mode 100644 index 0000000000..588de82c0e --- /dev/null +++ b/editor/grida-canvas/__tests__/utils/factories.ts @@ -0,0 +1,150 @@ +/** + * Node factory functions and event data factories for tests. + */ +import type grida from "@grida/schema"; +import color from "@grida/color"; +import type { editor } from "@/grida-canvas"; + +/** + * Create a minimal scene node. + */ +export function sceneNode( + id: string, + name?: string +): grida.program.nodes.SceneNode { + return { + type: "scene", + id, + name: name ?? id, + active: true, + locked: false, + constraints: { children: "multiple" }, + guides: [], + edges: [], + background_color: null, + }; +} + +/** + * Create a minimal rectangle node at a given position. + */ +export function rectNode( + id: string, + opts?: { + name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + } +): grida.program.nodes.RectangleNode { + return { + id, + type: "rectangle", + name: opts?.name ?? id, + active: true, + locked: false, + layout_positioning: "absolute", + layout_inset_left: opts?.x ?? 0, + layout_inset_top: opts?.y ?? 0, + layout_target_width: opts?.width ?? 100, + layout_target_height: opts?.height ?? 100, + rotation: 0, + opacity: 1, + z_index: 0, + corner_radius: 0, + stroke_width: 0, + stroke_cap: "butt", + stroke_join: "miter", + fill: { + type: "solid", + color: color.colorformats.RGBA32F.BLACK, + active: true, + }, + } as grida.program.nodes.RectangleNode; +} + +/** + * Create a minimal text span node. + */ +export function textNode( + id: string, + text?: string +): grida.program.nodes.TextSpanNode { + return { + id, + type: "tspan", + name: id, + active: true, + locked: false, + layout_positioning: "absolute", + layout_inset_left: 0, + layout_inset_top: 0, + layout_target_width: 200, + layout_target_height: 40, + rotation: 0, + opacity: 1, + z_index: 0, + text: text ?? "Hello", + font_family: "Inter", + font_weight: 400, + font_size: 16, + line_height: 1.2, + letter_spacing: 0, + text_align: "left", + text_align_vertical: "top", + fill: { + type: "solid", + color: color.colorformats.RGBA32F.BLACK, + active: true, + }, + } as grida.program.nodes.TextSpanNode; +} + +/** + * Create a minimal container node. + * + * TODO: Fill in all required ContainerNode fields (layout_mode, + * layout_direction, layout_main_axis_alignment, etc.) so the + * `as unknown` cast can be removed. + */ +export function containerNode( + id: string, + name?: string +): grida.program.nodes.ContainerNode { + return { + id, + type: "container", + name: name ?? id, + active: true, + locked: false, + layout_positioning: "absolute", + layout_inset_left: 0, + layout_inset_top: 0, + layout_target_width: 400, + layout_target_height: 400, + rotation: 0, + opacity: 1, + z_index: 0, + layout: "flow", + clips_content: false, + } as unknown as grida.program.nodes.ContainerNode; +} + +/** + * Create a mock pointer event data object for headless surface testing. + * Satisfies `editor.api.events.IPointerEvent`. + */ +export function mockPointerEvent( + opts?: Partial +): editor.api.events.IPointerEvent { + return { + clientX: opts?.clientX ?? 0, + clientY: opts?.clientY ?? 0, + button: opts?.button ?? 0, + shiftKey: opts?.shiftKey ?? false, + ctrlKey: opts?.ctrlKey ?? false, + metaKey: opts?.metaKey ?? false, + altKey: opts?.altKey ?? false, + }; +} diff --git a/editor/grida-canvas/__tests__/utils/fixtures.ts b/editor/grida-canvas/__tests__/utils/fixtures.ts new file mode 100644 index 0000000000..57bfca5070 --- /dev/null +++ b/editor/grida-canvas/__tests__/utils/fixtures.ts @@ -0,0 +1,74 @@ +/** + * Reusable document fixtures for tests. + */ +import type grida from "@grida/schema"; +import { sceneNode, rectNode, textNode } from "./factories"; + +/** + * Minimal valid document with one empty scene. + */ +export const MINIMAL_DOCUMENT: grida.program.document.Document = { + scenes_ref: ["scene"], + links: { + scene: [], + }, + nodes: { + scene: sceneNode("scene", "Scene"), + }, + entry_scene_id: "scene", + images: {}, + bitmaps: {}, + properties: {}, +}; + +/** + * Document with a scene and two rectangle nodes. + */ +export function createDocumentWithRects( + count: number = 2 +): grida.program.document.Document { + const rects: string[] = []; + const nodes: Record = { + scene: sceneNode("scene", "Scene"), + }; + + for (let i = 0; i < count; i++) { + const id = `rect-${i}`; + rects.push(id); + nodes[id] = rectNode(id, { + name: `Rectangle ${i}`, + x: i * 120, + y: 0, + }); + } + + return { + scenes_ref: ["scene"], + links: { scene: rects }, + nodes, + entry_scene_id: "scene", + images: {}, + bitmaps: {}, + properties: {}, + }; +} + +/** + * Document with a scene and one text node. + */ +export function createDocumentWithTextNode( + text?: string +): grida.program.document.Document { + return { + scenes_ref: ["scene"], + links: { scene: ["text-1"] }, + nodes: { + scene: sceneNode("scene", "Scene"), + "text-1": textNode("text-1", text), + }, + entry_scene_id: "scene", + images: {}, + bitmaps: {}, + properties: {}, + }; +} diff --git a/editor/grida-canvas/__tests__/utils/index.ts b/editor/grida-canvas/__tests__/utils/index.ts new file mode 100644 index 0000000000..e10f472e55 --- /dev/null +++ b/editor/grida-canvas/__tests__/utils/index.ts @@ -0,0 +1,20 @@ +/** + * Shared test utilities for grida-canvas headless testing. + * + * Consolidates the geometry stubs, context factories, document fixtures, + * and node factories that were previously copy-pasted across 6+ test files. + */ +export { createHeadlessEditor, type HeadlessEditorOptions } from "./create-headless-editor"; +export { geometryStub, createReducerContext } from "./stubs"; +export { + MINIMAL_DOCUMENT, + createDocumentWithRects, + createDocumentWithTextNode, +} from "./fixtures"; +export { + sceneNode, + rectNode, + textNode, + containerNode, + mockPointerEvent, +} from "./factories"; diff --git a/editor/grida-canvas/__tests__/utils/stubs.ts b/editor/grida-canvas/__tests__/utils/stubs.ts new file mode 100644 index 0000000000..6bcabcb5ec --- /dev/null +++ b/editor/grida-canvas/__tests__/utils/stubs.ts @@ -0,0 +1,40 @@ +/** + * Shared stubs for reducer-level testing. + */ +import { editor } from "@/grida-canvas"; +import type { ReducerContext } from "@/grida-canvas/reducers"; +import grida from "@grida/schema"; + +/** + * No-op geometry stub for reducer tests. + * Returns empty arrays for hit-testing and a fixed 100x100 rect. + */ +export const geometryStub: editor.api.IDocumentGeometryQuery = { + getNodeIdsFromPoint: () => [], + getNodeIdsFromPointerEvent: () => [], + getNodeIdsFromEnvelope: () => [], + getNodeAbsoluteBoundingRect: () => ({ + x: 0, + y: 0, + width: 100, + height: 100, + }), + getNodeAbsoluteRotation: () => 0, +}; + +/** + * Create a ReducerContext with sensible defaults for tests. + */ +export function createReducerContext( + overrides?: Partial +): ReducerContext { + return { + geometry: geometryStub, + vector: undefined, + viewport: { width: 1000, height: 1000 }, + backend: "dom" as const, + paint_constraints: { fill: "fill", stroke: "stroke" }, + idgen: grida.id.noop.generator, + ...overrides, + }; +} diff --git a/editor/grida-canvas/backends/dom.ts b/editor/grida-canvas/backends/dom.ts index 94610ab574..cdb24a6200 100644 --- a/editor/grida-canvas/backends/dom.ts +++ b/editor/grida-canvas/backends/dom.ts @@ -1,4 +1,5 @@ import cmath from "@grida/cmath"; +import type { editor } from ".."; import assert from "assert"; @@ -17,7 +18,7 @@ export namespace domapi { export const EDITOR_CONTENT_ELEMENT_ID = "grida-canvas-sdk-editor-content"; } - export class DOMViewportApi { + export class DOMViewportApi implements editor.api.IViewportApi { constructor( readonly element: string | HTMLElement = k.VIEWPORT_ELEMENT_ID ) { diff --git a/editor/grida-canvas/backends/headless.ts b/editor/grida-canvas/backends/headless.ts new file mode 100644 index 0000000000..ff05760aa8 --- /dev/null +++ b/editor/grida-canvas/backends/headless.ts @@ -0,0 +1,38 @@ +/** + * Headless backend providers for running the Editor in Node.js / Vitest + * without any browser globals. + */ +import cmath from "@grida/cmath"; +import type { editor } from ".."; + +/** + * Viewport API that returns fixed dimensions. + * No browser globals required. + */ +export class HeadlessViewportApi implements editor.api.IViewportApi { + constructor( + public width = 1920, + public height = 1080 + ) {} + + get offset(): cmath.Vector2 { + return [0, 0]; + } + + get rect() { + return { + left: 0, + top: 0, + right: this.width, + bottom: this.height, + width: this.width, + height: this.height, + x: 0, + y: 0, + }; + } + + get size() { + return { width: this.width, height: this.height }; + } +} diff --git a/editor/grida-canvas/backends/index.ts b/editor/grida-canvas/backends/index.ts index 2e5900aee3..526c2d13ad 100644 --- a/editor/grida-canvas/backends/index.ts +++ b/editor/grida-canvas/backends/index.ts @@ -2,5 +2,6 @@ export * from "./dom"; export * from "./dom-content"; export * from "./dom-export"; export * from "./noop"; +export * from "./headless"; export * from "./dom-fonts"; export * from "./wasm"; diff --git a/editor/grida-canvas/backends/noop.ts b/editor/grida-canvas/backends/noop.ts index 8d822cb7dc..161b30d341 100644 --- a/editor/grida-canvas/backends/noop.ts +++ b/editor/grida-canvas/backends/noop.ts @@ -14,10 +14,7 @@ export class NoopGeometryQueryInterfaceProvider getNodeAbsoluteBoundingRect(node_id: string): cmath.Rectangle | null { return null; } - getNodeIdsFromPointerEvent(event: { - clientX: number; - clientY: number; - }): string[] { + getNodeIdsFromPointerEvent(event: editor.api.events.IPointerEvent): string[] { return []; } } @@ -42,3 +39,18 @@ export class NoopDefaultExportInterfaceProvider throw new Error("Not implemented"); } } + +/** + * No-op properties query provider for headless / test use. + */ +export class NoopPropertiesQueryProvider + implements editor.api.IDocumentPropertiesQueryProvider +{ + queryPaintGroups( + _ids: string[], + _target: "fill" | "stroke", + _options?: { recursive?: boolean; limit?: number } + ): editor.api.PaintGroup[] { + return []; + } +} diff --git a/editor/grida-canvas/commands/text-edit.ts b/editor/grida-canvas/commands/text-edit.ts index 070f29fe66..783058e6d8 100644 --- a/editor/grida-canvas/commands/text-edit.ts +++ b/editor/grida-canvas/commands/text-edit.ts @@ -7,6 +7,7 @@ */ import type { TextEditCommand } from "@grida/canvas-wasm"; +import type { editor } from ".."; const IS_MAC = typeof navigator !== "undefined" && @@ -18,7 +19,7 @@ const IS_MAC = * command (e.g. it's a modifier-only press or an unrecognized key). */ export function keyEventToTextEditCommand( - e: KeyboardEvent + e: editor.api.events.IKeyboardEvent ): TextEditCommand | null { const mod = IS_MAC ? e.metaKey : e.ctrlKey; const shift = e.shiftKey; diff --git a/editor/grida-canvas/editor.i.ts b/editor/grida-canvas/editor.i.ts index b9119f653d..9225841012 100644 --- a/editor/grida-canvas/editor.i.ts +++ b/editor/grida-canvas/editor.i.ts @@ -2563,10 +2563,10 @@ export namespace editor.api { export interface IDocumentGeometryInterfaceProvider { /** * returns a list of node ids that are intersecting with the pointer event - * @param event window event + * @param event window event (or any object satisfying IPointerEventData) * @returns */ - getNodeIdsFromPointerEvent(event: PointerEvent | MouseEvent): string[]; + getNodeIdsFromPointerEvent(event: events.IPointerEvent): string[]; /** * returns a list of node ids that are intersecting with the point in canvas space @@ -2808,6 +2808,86 @@ export namespace editor.api { // + /** + * Abstraction over the viewport measurement surface. + * + * Browser callers use `DOMViewportApi` (backed by `getBoundingClientRect`); + * headless / test callers use `HeadlessViewportApi` with fixed dimensions. + */ + export interface IViewportApi { + readonly offset: cmath.Vector2; + readonly rect: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + x: number; + y: number; + }; + readonly size: { width: number; height: number }; + } + + /** + * Platform-neutral event data types consumed by the editor. + * + * These are intentionally *not* DOM types — they carry only the fields + * the editor actually reads. Browser DOM events (`PointerEvent`, + * `MouseEvent`, `KeyboardEvent`, `FocusEvent`) structurally satisfy + * these interfaces via duck typing, so browser call-sites need zero + * changes. + */ + export namespace events { + /** + * Base modifier-key state shared by all event IR types. + */ + export interface IModifiers { + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + } + + /** + * Minimal pointer/mouse event data. + * + * TODO: Consider adding `pointerId` and `pointerType` if surface + * methods ever need to distinguish pen vs touch vs mouse input. + */ + export interface IPointerEvent extends IModifiers { + clientX: number; + clientY: number; + button: number; + } + + /** + * Minimal keyboard event data. + */ + export interface IKeyboardEvent extends IModifiers { + key: string; + preventDefault: () => void; + stopPropagation: () => void; + } + + /** + * Minimal focus/blur event data. + * In headless tests, call `onblur()` with no arguments or `{}`. + */ + export interface IFocusEvent { + defaultPrevented?: boolean; + } + } + + // Re-export at api level for backwards compat & convenience. + // Existing code using `editor.api.IPointerEventData` keeps working. + /** @deprecated Use `editor.api.events.IPointerEvent` */ + export type IPointerEventData = events.IPointerEvent; + /** @deprecated Use `editor.api.events.IKeyboardEvent` */ + export type IKeyboardEventData = events.IKeyboardEvent; + /** @deprecated Use `editor.api.events.IFocusEvent` */ + export type IFocusEventData = events.IFocusEvent; + export interface IDocumentGeometryQuery { /** * returns a list of node ids that are intersecting with the point in canvas space @@ -2817,10 +2897,10 @@ export namespace editor.api { getNodeIdsFromPoint(point: cmath.Vector2): string[]; /** * returns a list of node ids that are intersecting with the pointer event - * @param event window event + * @param event window event (or any object satisfying IPointerEventData) * @returns */ - getNodeIdsFromPointerEvent(event: PointerEvent | MouseEvent): string[]; + getNodeIdsFromPointerEvent(event: events.IPointerEvent): string[]; /** * returns a list of node ids that are intersecting with the envelope in canvas space * @param envelope canvas space envelope @@ -4455,7 +4535,8 @@ export namespace editor.api { * - Resets all surface configurations (raycast targeting, measurement, modifiers) * - Resets tool to cursor (safe default) * - * The callback signature matches `window.addEventListener("blur", callback)`. + * The callback signature is compatible with `window.addEventListener("blur", callback)` + * because browser `FocusEvent` structurally satisfies `IFocusEventData`. * * @example * ```typescript @@ -4464,7 +4545,7 @@ export namespace editor.api { * window.removeEventListener("blur", editor.surface.onblur); * ``` */ - onblur(event: FocusEvent): void; + onblur(event?: events.IFocusEvent): void; // } diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index 541844f5ad..aac377402a 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -23,6 +23,11 @@ import { } from "./backends"; import { DOMPropertiesQueryProvider } from "./backends/dom-content"; import { domapi } from "./backends/dom"; +import { HeadlessViewportApi } from "./backends/headless"; +import { + NoopPropertiesQueryProvider, + NoopGeometryQueryInterfaceProvider, +} from "./backends/noop"; import { dq } from "@/grida-canvas/query"; import { resolveInsertTargetParent, @@ -83,7 +88,7 @@ function resolveWithEditorInstance( export class Camera implements editor.api.ICameraActions { constructor( readonly editor: Editor, - readonly viewport: domapi.DOMViewportApi + readonly viewport: editor.api.IViewportApi ) { // } @@ -273,7 +278,7 @@ export class Camera implements editor.api.ICameraActions { * @returns viewport relative point */ public pointerEventToViewportPoint = ( - pointer_event: PointerEvent | MouseEvent + pointer_event: editor.api.events.IPointerEvent ) => { const { clientX, clientY } = pointer_event; @@ -2855,16 +2860,32 @@ export class Editor ui = {}, backend, viewportElement, + viewportApi, geometry, initialState, interfaces = {}, + properties_query, onCreate, onMount, + __skipWarmup, }: { logger?: (...args: any[]) => void; ui?: editor.ui.UIUXProviders; backend: editor.EditorContentRenderingBackend; - viewportElement: string | HTMLElement; + /** + * DOM viewport element (string ID or HTMLElement). + * Mutually exclusive with `viewportApi`. For browser use. + * + * TODO: Refactor `viewportElement` and `viewportApi` into a discriminated + * union type to make mutual exclusivity a compile-time guarantee instead + * of a runtime check. + */ + viewportElement?: string | HTMLElement; + /** + * Pre-constructed viewport API. + * Mutually exclusive with `viewportElement`. For headless / test use. + */ + viewportApi?: editor.api.IViewportApi; geometry: | editor.api.IDocumentGeometryInterfaceProvider | ((editor: Editor) => editor.api.IDocumentGeometryInterfaceProvider); @@ -2879,11 +2900,32 @@ export class Editor svg?: WithEditorInstance; markdown?: WithEditorInstance; }; + /** + * Custom properties query provider. + * When omitted in headless mode, a noop provider is used. + * When omitted in browser mode, DOMPropertiesQueryProvider is used. + */ + properties_query?: editor.api.IDocumentPropertiesQueryProvider; + /** + * @internal Skip warmup (font list fetch). Used by headless factory. + */ + __skipWarmup?: boolean; }) { this.logger = logger; this.onMount = onMount; this.backend = backend; - this.camera = new Camera(this, new domapi.DOMViewportApi(viewportElement)); + + // Resolve viewport: prefer explicit API, fall back to DOM wrapper + if (!viewportApi && viewportElement == null) { + throw new Error( + "Editor requires either viewportElement or viewportApi" + ); + } + const resolvedViewport: editor.api.IViewportApi = viewportApi + ? viewportApi + : new domapi.DOMViewportApi(viewportElement!); + this.camera = new Camera(this, resolvedViewport); + this.doc = new EditorDocumentStore( grida.id.noop.generator, // test only // // TODO: resolve from server @@ -2908,7 +2950,8 @@ export class Editor this._m_geometry = typeof geometry === "function" ? geometry(this) : geometry; - this._m_properties_query = new DOMPropertiesQueryProvider(this); + this._m_properties_query = + properties_query ?? new DOMPropertiesQueryProvider(this); if (interfaces?.exporter) { this._m_exporter = resolveWithEditorInstance(this, interfaces.exporter); @@ -2942,13 +2985,58 @@ export class Editor this._fontManager = new DocumentFontManager(this); - this._do_legacy_warmup(); + if (!__skipWarmup) { + this._do_legacy_warmup(); + } this.commands = this.doc; onCreate?.(this); this.log("editor instantiated"); } + /** + * Create a headless Editor instance that runs in Node.js / Vitest + * without any browser globals (window, document, navigator, DOM types). + * + * The headless editor supports the full action->reduce->state pipeline, + * subscriptions, undo/redo, and all pure-logic operations. It does NOT + * support WASM rendering, font network loading, or image dimension + * detection via DOM `Image`. + * + * @example + * ```ts + * const ed = Editor.createHeadless({ + * document: { nodes: { main: { ... } }, ... }, + * editable: true, + * }); + * ed.doc.select(["node-1"]); + * expect(ed.state.selection).toContain("node-1"); + * ed.dispose(); + * ``` + */ + static createHeadless( + initialState: editor.state.IEditorStateInit, + options?: { + viewport?: { width: number; height: number }; + logger?: (...args: any[]) => void; + } + ): Editor { + const viewport = new HeadlessViewportApi( + options?.viewport?.width ?? 1920, + options?.viewport?.height ?? 1080 + ); + + return new Editor({ + backend: "canvas", + viewportApi: viewport, + geometry: new NoopGeometryQueryInterfaceProvider(), + properties_query: new NoopPropertiesQueryProvider(), + initialState, + logger: options?.logger ?? (() => {}), + __skipWarmup: true, + }); + } + /** * legacy warmup - ideally, this should be called externally, or once internallu, * but as we allow dynamic surface binding, this proccess shall be duplicated once surface binded as well. @@ -3758,7 +3846,7 @@ export class Editor // #region IDocumentGeometryQuery implementation public getNodeIdsFromPointerEvent( - event: PointerEvent | MouseEvent + event: editor.api.events.IPointerEvent ): string[] { return this.geometryProvider.getNodeIdsFromPointerEvent(event); } @@ -4551,8 +4639,8 @@ export class EditorSurface * * This ensures the editor is in a consistent, predictable state when the user returns. */ - public onblur(event: FocusEvent): void { - if (event.defaultPrevented) return; + public onblur(event?: editor.api.events.IFocusEvent): void { + if (event?.defaultPrevented) return; // Clear stuck title bar hover state // This handles edge case where pointerLeave never fires (e.g., tab switch, window blur) @@ -4837,7 +4925,10 @@ export class EditorSurface // #region IEventTargetActions implementation private _throttled_pointer_move_with_raycast = editor.throttle( - (event: PointerEvent, position: { x: number; y: number }) => { + ( + event: editor.api.events.IPointerEvent, + position: { x: number; y: number } + ) => { // this is throttled - as it is expensive const ids = this._editor.getNodeIdsFromPointerEvent(event); this._editor.doc.dispatch({ @@ -4850,7 +4941,7 @@ export class EditorSurface this.__pointer_move_throttle_ms ); - surfacePointerDown(event: PointerEvent) { + surfacePointerDown(event: editor.api.events.IPointerEvent) { const ids = this._editor.getNodeIdsFromPointerEvent(event); this._editor.doc.dispatch({ @@ -4860,13 +4951,13 @@ export class EditorSurface }); } - surfacePointerUp(event: PointerEvent) { + surfacePointerUp(event: editor.api.events.IPointerEvent) { this._editor.doc.dispatch({ type: "event-target/event/on-pointer-up", }); } - surfacePointerMove(event: PointerEvent) { + surfacePointerMove(event: editor.api.events.IPointerEvent) { const position = this.camera.pointerEventToViewportPoint(event); this._editor.doc.dispatch({ @@ -4878,7 +4969,7 @@ export class EditorSurface this._throttled_pointer_move_with_raycast(event, position); } - surfaceClick(event: MouseEvent) { + surfaceClick(event: editor.api.events.IPointerEvent) { const ids = this._editor.getNodeIdsFromPointerEvent(event); this._editor.doc.dispatch({ @@ -4888,13 +4979,16 @@ export class EditorSurface }); } - surfaceDoubleClick(event: MouseEvent) { + surfaceDoubleClick(event: editor.api.events.IPointerEvent) { this._editor.doc.dispatch({ type: "event-target/event/on-double-click", }); } - surfaceMultipleSelectionOverlayClick(group: string[], event: MouseEvent) { + surfaceMultipleSelectionOverlayClick( + group: string[], + event: editor.api.events.IPointerEvent + ) { const ids = this._editor.getNodeIdsFromPointerEvent(event); this._editor.doc.dispatch({ type: "event-target/event/multiple-selection-overlay/on-click", @@ -4904,14 +4998,14 @@ export class EditorSurface }); } - surfaceDragStart(event: PointerEvent) { + surfaceDragStart(event: editor.api.events.IPointerEvent) { this._editor.doc.dispatch({ type: "event-target/event/on-drag-start", shiftKey: event.shiftKey, }); } - surfaceDragEnd(event: PointerEvent) { + surfaceDragEnd(event: editor.api.events.IPointerEvent) { const { marquee } = this._editor.doc.state; if (marquee) { // test area in canvas space @@ -5115,7 +5209,17 @@ export class EditorSurface constraints: { type: "scale", value: 1 }, }); const blob = new Blob([data as BlobPart], { type: "image/png" }); - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + const item = new ClipboardItem({ "image/png": blob }); + if (this.ui.clipboard) { + await this.ui.clipboard.write([item]); + } else if ( + typeof navigator !== "undefined" && + navigator.clipboard?.write + ) { + await navigator.clipboard.write([item]); + } else { + return false; + } return true; } @@ -6056,14 +6160,9 @@ export class EditorSurface * /> * ``` */ - public explicitlyOverrideInputUndoRedo(event: { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - preventDefault: () => void; - stopPropagation: () => void; - }): boolean { + public explicitlyOverrideInputUndoRedo( + event: editor.api.events.IKeyboardEvent + ): boolean { // Check if this is Cmd+Z (undo) or Cmd+Shift+Z (redo) const isCmdOrCtrl = event.metaKey || event.ctrlKey; const isZKey = event.key === "z" || event.key === "Z"; diff --git a/editor/grida-canvas/reducers/__tests__/history.test.ts b/editor/grida-canvas/reducers/__tests__/history.test.ts index a9aa087e6e..2c8e33eec1 100644 --- a/editor/grida-canvas/reducers/__tests__/history.test.ts +++ b/editor/grida-canvas/reducers/__tests__/history.test.ts @@ -1,102 +1,23 @@ -import reducer, { type ReducerContext } from "../index"; -import { DocumentHistoryManager } from "../../history-manager"; -import { editor } from "@/grida-canvas"; -import grida from "@grida/schema"; -import color from "@grida/color"; -import type { Action } from "../../action"; +/** + * @vitest-environment node + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { vi } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; +import { sceneNode, rectNode } from "@/grida-canvas/__tests__/utils/factories"; +import type grida from "@grida/schema"; -// Mock geometry interface -const geometryStub: editor.api.IDocumentGeometryQuery = { - getNodeIdsFromPoint: () => [], - getNodeIdsFromPointerEvent: () => [], - getNodeIdsFromEnvelope: () => [], - getNodeAbsoluteBoundingRect: () => ({ - x: 0, - y: 0, - width: 100, - height: 100, - }), - getNodeAbsoluteRotation: () => 0, -}; - -function createContext(): ReducerContext { - return { - geometry: geometryStub, - vector: undefined, - viewport: { width: 1000, height: 1000 }, - backend: "dom" as const, - paint_constraints: { fill: "fill", stroke: "stroke" }, - idgen: grida.id.noop.generator, - }; -} - -function createDocument(): grida.program.document.Document { +function createHistoryDocument(): grida.program.document.Document { return { scenes_ref: ["scene1"], links: { scene1: ["rect1", "rect2"], }, nodes: { - scene1: { - type: "scene", - id: "scene1", - name: "Scene 1", - active: true, - locked: false, - constraints: { children: "multiple" }, - guides: [], - edges: [], - background_color: null, - }, - rect1: { - id: "rect1", - type: "rectangle", - name: "Rectangle 1", - active: true, - locked: false, - layout_positioning: "absolute", - layout_inset_left: 0, - layout_inset_top: 0, - layout_target_width: 100, - layout_target_height: 100, - rotation: 0, - opacity: 1, - z_index: 0, - corner_radius: 0, - stroke_width: 0, - stroke_cap: "butt", - stroke_join: "miter", - fill: { - type: "solid", - color: color.colorformats.RGBA32F.BLACK, - active: true, - }, - }, - rect2: { - id: "rect2", - type: "rectangle", - name: "Rectangle 2", - active: true, - locked: false, - layout_positioning: "absolute", - layout_inset_left: 200, - layout_inset_top: 0, - layout_target_width: 100, - layout_target_height: 100, - rotation: 0, - opacity: 1, - z_index: 0, - corner_radius: 0, - stroke_width: 0, - stroke_cap: "butt", - stroke_join: "miter", - fill: { - type: "solid", - color: color.colorformats.RGBA32F.BLACK, - active: true, - }, - }, + scene1: sceneNode("scene1", "Scene 1"), + rect1: rectNode("rect1", { name: "Rectangle 1" }), + rect2: rectNode("rect2", { name: "Rectangle 2", x: 200 }), }, entry_scene_id: "scene1", bitmaps: {}, @@ -105,187 +26,138 @@ function createDocument(): grida.program.document.Document { }; } -function createState() { - return editor.state.init({ - editable: true, - debug: false, - document: createDocument(), - templates: {}, +describe("History Management", () => { + let ed: Editor; + + beforeEach(() => { + ed = createHeadlessEditor({ document: createHistoryDocument() }); }); -} -describe("History Management", () => { - const context = createContext(); - - function dispatchWithHistory( - history: DocumentHistoryManager, - state: editor.state.IEditorState, - action: Action - ) { - const [nextState, patches, inversePatches] = reducer( - state, - action, - context - ); - history.record({ actionType: action.type, patches, inversePatches }); - return nextState; - } + afterEach(() => { + ed.dispose(); + }); describe("Basic History Operations", () => { test("records and replays selection changes", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Initial state - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(0); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(0); // Select first rectangle - const selectAction1: Action = { type: "select", selection: ["rect1"] }; - state = dispatchWithHistory(history, state, selectAction1); - expect(state.selection).toEqual(["rect1"]); - expect(history.snapshot.past).toHaveLength(1); - expect(history.snapshot.past[0].actionType).toBe("select"); - expect(history.snapshot.past[0].patches).toHaveLength(1); - - // Select second rectangle (should be merged with first) - const selectAction2: Action = { type: "select", selection: ["rect2"] }; - state = dispatchWithHistory(history, state, selectAction2); - expect(state.selection).toEqual(["rect2"]); - expect(history.snapshot.past).toHaveLength(1); // Merged into single entry + ed.doc.select(["rect1"]); + expect(ed.state.selection).toEqual(["rect1"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.doc.historySnapshot.past[0].actionType).toBe("select"); + expect(ed.doc.historySnapshot.past[0].patches).toHaveLength(1); + + // Select second rectangle (should be merged with first — rapid selection) + ed.doc.select(["rect2"]); + expect(ed.state.selection).toEqual(["rect2"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // Merged into single entry // Undo to initial state (merged entries go back to initial state) - [state] = history.undo(state); - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(1); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(0); + expect(ed.doc.historySnapshot.future).toHaveLength(1); // Redo to final selection (merged entries go directly to final state) - [state] = history.redo(state); - expect(state.selection).toEqual(["rect2"]); - expect(history.snapshot.past).toHaveLength(1); - expect(history.snapshot.future).toHaveLength(0); + ed.doc.redo(); + expect(ed.state.selection).toEqual(["rect2"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.doc.historySnapshot.future).toHaveLength(0); }); test("records and replays node deletion", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Select and delete a node - const selectAction: Action = { type: "select", selection: ["rect2"] }; - state = dispatchWithHistory(history, state, selectAction); - expect(state.selection).toEqual(["rect2"]); + ed.doc.select(["rect2"]); + expect(ed.state.selection).toEqual(["rect2"]); - const deleteAction: Action = { type: "delete", target: ["rect2"] }; - state = dispatchWithHistory(history, state, deleteAction); - expect(state.document.nodes.rect2).toBeUndefined(); - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(1); // select+delete merged + ed.doc.delete(["rect2"]); + expect(ed.state.document.nodes.rect2).toBeUndefined(); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // select+delete merged // Undo deletion (goes back to initial state) - [state] = history.undo(state); - expect(state.document.nodes.rect2).toBeDefined(); - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(1); + ed.doc.undo(); + expect(ed.state.document.nodes.rect2).toBeDefined(); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(0); + expect(ed.doc.historySnapshot.future).toHaveLength(1); // Redo deletion (goes to final state) - [state] = history.redo(state); - expect(state.document.nodes.rect2).toBeUndefined(); - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(1); - expect(history.snapshot.future).toHaveLength(0); + ed.doc.redo(); + expect(ed.state.document.nodes.rect2).toBeUndefined(); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.doc.historySnapshot.future).toHaveLength(0); }); test("clears future when new action is recorded", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Create some history (rapid selections will be merged) - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect2"], - }); - expect(history.snapshot.past).toHaveLength(1); // merged + ed.doc.select(["rect1"]); + ed.doc.select(["rect2"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // merged // Undo once - [state] = history.undo(state); - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(1); + ed.doc.undo(); + expect(ed.doc.historySnapshot.past).toHaveLength(0); + expect(ed.doc.historySnapshot.future).toHaveLength(1); // Record new action - should clear future - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1", "rect2"], - }); - expect(history.snapshot.past).toHaveLength(1); // new action - expect(history.snapshot.future).toHaveLength(0); + ed.doc.select(["rect1", "rect2"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // new action + expect(ed.doc.historySnapshot.future).toHaveLength(0); }); }); describe("History Merging", () => { test("merges rapid selection updates", () => { - const history = new DocumentHistoryManager(); - let state = createState(); const nowSpy = vi.spyOn(Date, "now"); // First selection nowSpy.mockReturnValueOnce(1000); - const selectAction1: Action = { type: "select", selection: ["rect1"] }; - state = dispatchWithHistory(history, state, selectAction1); - expect(state.selection).toEqual(["rect1"]); - expect(history.snapshot.past).toHaveLength(1); + ed.doc.select(["rect1"]); + expect(ed.state.selection).toEqual(["rect1"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // Second selection within merge window (100ms) nowSpy.mockReturnValueOnce(1100); - nowSpy.mockReturnValueOnce(1100); // Mock twice for two calls to Date.now() - const selectAction2: Action = { type: "select", selection: ["rect2"] }; - state = dispatchWithHistory(history, state, selectAction2); + nowSpy.mockReturnValueOnce(1100); + ed.doc.select(["rect2"]); // Should be merged into single entry - expect(history.snapshot.past).toHaveLength(1); - expect(state.selection).toEqual(["rect2"]); - expect(history.snapshot.past[0].patches).toHaveLength(2); // Two patches in one entry + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.state.selection).toEqual(["rect2"]); + expect(ed.doc.historySnapshot.past[0].patches).toHaveLength(2); // Undo should go to initial state - [state] = history.undo(state); - expect(state.selection).toEqual([]); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); // Redo should go to final state - [state] = history.redo(state); - expect(state.selection).toEqual(["rect2"]); + ed.doc.redo(); + expect(ed.state.selection).toEqual(["rect2"]); nowSpy.mockRestore(); }); test("does not merge actions after merge window", () => { - const history = new DocumentHistoryManager(); - let state = createState(); const nowSpy = vi.spyOn(Date, "now"); // First selection nowSpy.mockReturnValueOnce(1000); - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - expect(history.snapshot.past).toHaveLength(1); + ed.doc.select(["rect1"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // Second selection after merge window (> 300ms) nowSpy.mockReturnValueOnce(1401); nowSpy.mockReturnValueOnce(1401); - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect2"], - }); + ed.doc.select(["rect2"]); // Should create separate entries (outside merge window) - expect(history.snapshot.past).toHaveLength(2); - expect(state.selection).toEqual(["rect2"]); + expect(ed.doc.historySnapshot.past).toHaveLength(2); + expect(ed.state.selection).toEqual(["rect2"]); nowSpy.mockRestore(); }); @@ -293,106 +165,61 @@ describe("History Management", () => { describe("History Manager State", () => { test("provides correct snapshot", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Initial snapshot - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(0); + expect(ed.doc.historySnapshot.past).toHaveLength(0); + expect(ed.doc.historySnapshot.future).toHaveLength(0); // After one action - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - expect(history.snapshot.past).toHaveLength(1); - expect(history.snapshot.future).toHaveLength(0); + ed.doc.select(["rect1"]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.doc.historySnapshot.future).toHaveLength(0); // After undo - [state] = history.undo(state); - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(1); + ed.doc.undo(); + expect(ed.doc.historySnapshot.past).toHaveLength(0); + expect(ed.doc.historySnapshot.future).toHaveLength(1); // After redo - [state] = history.redo(state); - expect(history.snapshot.past).toHaveLength(1); - expect(history.snapshot.future).toHaveLength(0); - }); - - test("clears history", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - - // Create some history (rapid selections will be merged) - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect2"], - }); - expect(history.snapshot.past).toHaveLength(1); // Merged - - // Clear history - history.clear(); - expect(history.snapshot.past).toHaveLength(0); - expect(history.snapshot.future).toHaveLength(0); + ed.doc.redo(); + expect(ed.doc.historySnapshot.past).toHaveLength(1); + expect(ed.doc.historySnapshot.future).toHaveLength(0); }); }); describe("Complex Scenarios", () => { test("handles multiple operations with undo/redo", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Select, delete, select another - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - state = dispatchWithHistory(history, state, { - type: "delete", - target: ["rect1"], - }); - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect2"], - }); - - expect(history.snapshot.past).toHaveLength(1); // select+delete+select all merged - expect(state.document.nodes.rect1).toBeUndefined(); - expect(state.selection).toEqual(["rect2"]); + ed.doc.select(["rect1"]); + ed.doc.delete(["rect1"]); + ed.doc.select(["rect2"]); + + expect(ed.doc.historySnapshot.past).toHaveLength(1); // select+delete+select all merged + expect(ed.state.document.nodes.rect1).toBeUndefined(); + expect(ed.state.selection).toEqual(["rect2"]); // Undo all operations (goes back to initial state) - [state] = history.undo(state); - expect(state.selection).toEqual([]); - expect(state.document.nodes.rect1).toBeDefined(); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); + expect(ed.state.document.nodes.rect1).toBeDefined(); // Redo all operations (goes to final state) - [state] = history.redo(state); - expect(state.document.nodes.rect1).toBeUndefined(); - expect(state.selection).toEqual(["rect2"]); + ed.doc.redo(); + expect(ed.state.document.nodes.rect1).toBeUndefined(); + expect(ed.state.selection).toEqual(["rect2"]); }); test("handles blur action", () => { - const history = new DocumentHistoryManager(); - let state = createState(); - // Select and then blur - state = dispatchWithHistory(history, state, { - type: "select", - selection: ["rect1"], - }); - expect(state.selection).toEqual(["rect1"]); + ed.doc.select(["rect1"]); + expect(ed.state.selection).toEqual(["rect1"]); - state = dispatchWithHistory(history, state, { type: "blur" }); - expect(state.selection).toEqual([]); - expect(history.snapshot.past).toHaveLength(1); // select+blur merged + ed.doc.blur(); + expect(ed.state.selection).toEqual([]); + expect(ed.doc.historySnapshot.past).toHaveLength(1); // select+blur merged // Undo blur (goes back to initial state) - [state] = history.undo(state); - expect(state.selection).toEqual([]); + ed.doc.undo(); + expect(ed.state.selection).toEqual([]); }); }); }); diff --git a/editor/grida-canvas/reducers/__tests__/select-readonly.test.ts b/editor/grida-canvas/reducers/__tests__/select-readonly.test.ts index ba157185d1..f5df87d22e 100644 --- a/editor/grida-canvas/reducers/__tests__/select-readonly.test.ts +++ b/editor/grida-canvas/reducers/__tests__/select-readonly.test.ts @@ -1,230 +1,88 @@ -import reducer, { type ReducerContext } from "../index"; -import { editor } from "@/grida-canvas"; -import grida from "@grida/schema"; -import color from "@grida/color"; -import type { Action } from "../../action"; - -const geometryStub: editor.api.IDocumentGeometryQuery = { - getNodeIdsFromPoint: () => [], - getNodeIdsFromPointerEvent: () => [], - getNodeIdsFromEnvelope: () => [], - getNodeAbsoluteBoundingRect: () => ({ - x: 0, - y: 0, - width: 100, - height: 100, - }), - getNodeAbsoluteRotation: () => 0, -}; - -function createContext(): ReducerContext { - return { - geometry: geometryStub, - vector: undefined, - viewport: { width: 1000, height: 1000 }, - backend: "canvas" as const, - paint_constraints: { fill: "fill", stroke: "stroke" }, - idgen: grida.id.noop.generator, - }; -} - -function createDocument(): grida.program.document.Document { +/** + * @vitest-environment node + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; +import { sceneNode, rectNode } from "@/grida-canvas/__tests__/utils/factories"; +import type grida from "@grida/schema"; + +function createReadonlyDocument(): grida.program.document.Document { return { scenes_ref: ["scene1"], links: { scene1: ["node-1", "node-2"], }, nodes: { - scene1: { - type: "scene", - id: "scene1", - name: "Scene 1", - active: true, - locked: false, - constraints: { children: "multiple" }, - guides: [], - edges: [], - background_color: null, - }, - "node-1": { - id: "node-1", - type: "rectangle", - name: "Node 1", - active: true, - locked: false, - layout_positioning: "absolute", - layout_inset_left: 0, - layout_inset_top: 0, - layout_target_width: 100, - layout_target_height: 100, - rotation: 0, - opacity: 1, - z_index: 0, - corner_radius: 0, - stroke_width: 0, - stroke_cap: "butt", - stroke_join: "miter", - fill: { - type: "solid", - color: color.colorformats.RGBA32F.BLACK, - active: true, - }, - }, - "node-2": { - id: "node-2", - type: "rectangle", - name: "Node 2", - active: true, - locked: false, - layout_positioning: "absolute", - layout_inset_left: 200, - layout_inset_top: 0, - layout_target_width: 100, - layout_target_height: 100, - rotation: 0, - opacity: 1, - z_index: 0, - corner_radius: 0, - stroke_width: 0, - stroke_cap: "butt", - stroke_join: "miter", - fill: { - type: "solid", - color: color.colorformats.RGBA32F.BLACK, - active: true, - }, - }, + scene1: sceneNode("scene1", "Scene 1"), + "node-1": rectNode("node-1", { name: "Node 1" }), + "node-2": rectNode("node-2", { name: "Node 2", x: 200 }), }, entry_scene_id: "scene1", - bitmaps: {}, images: {}, + bitmaps: {}, properties: {}, }; } -function createState(editable: boolean) { - return editor.state.init({ - editable, - debug: false, - document: createDocument(), - templates: {}, - }); -} - -function dispatch( - state: editor.state.IEditorState, - action: Action, - context: ReducerContext -): editor.state.IEditorState { - const [nextState] = reducer(state, action, context); - return nextState; -} - describe("selection in readonly mode (editable: false)", () => { - const context = createContext(); + let ed: Editor; - test("select single node", () => { - let state = createState(false); - expect(state.selection).toEqual([]); + beforeEach(() => { + ed = createHeadlessEditor({ + document: createReadonlyDocument(), + editable: false, + }); + }); - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); + afterEach(() => { + ed.dispose(); + }); - expect(state.selection).toEqual(["node-1"]); + test("select single node", () => { + expect(ed.state.selection).toEqual([]); + ed.doc.select(["node-1"]); + expect(ed.state.selection).toEqual(["node-1"]); }); test("select multiple nodes", () => { - let state = createState(false); - - state = dispatch( - state, - { type: "select", selection: ["node-1", "node-2"] }, - context - ); - - expect(state.selection).toEqual(["node-1", "node-2"]); + ed.doc.select(["node-1", "node-2"]); + expect(ed.state.selection).toEqual(["node-1", "node-2"]); }); test("clear selection via blur", () => { - let state = createState(false); - - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); - expect(state.selection).toEqual(["node-1"]); - - state = dispatch(state, { type: "blur" }, context); - expect(state.selection).toEqual([]); + ed.doc.select(["node-1"]); + expect(ed.state.selection).toEqual(["node-1"]); + ed.doc.blur(); + expect(ed.state.selection).toEqual([]); }); test("replace existing selection", () => { - let state = createState(false); - - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); - state = dispatch( - state, - { type: "select", selection: ["node-2"] }, - context - ); - - expect(state.selection).toEqual(["node-2"]); + ed.doc.select(["node-1"]); + ed.doc.select(["node-2"]); + expect(ed.state.selection).toEqual(["node-2"]); }); test("does not mutate document nodes", () => { - let state = createState(false); - const originalNodes = state.document.nodes; - - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); - - expect(state.document.nodes).toBe(originalNodes); + const originalNodes = ed.state.document.nodes; + ed.doc.select(["node-1"]); + expect(ed.state.document.nodes).toBe(originalNodes); }); test("no-op when selection is unchanged", () => { - let state = createState(false); - - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); - const stateAfterFirst = state; - - state = dispatch( - state, - { type: "select", selection: ["node-1"] }, - context - ); - - expect(state).toBe(stateAfterFirst); + ed.doc.select(["node-1"]); + const stateAfterFirst = ed.state; + ed.doc.select(["node-1"]); + expect(ed.state).toBe(stateAfterFirst); }); test("document-mutating actions are still blocked", () => { - let state = createState(false); - const originalDoc = state.document; - - state = dispatch( - state, - { - type: "node/change/*", - node_id: "node-1", - name: "Modified", - }, - context - ); - - expect(state.document).toBe(originalDoc); + const originalDoc = ed.state.document; + ed.doc.dispatch({ + type: "node/change/*", + node_id: "node-1", + name: "Modified", + }); + expect(ed.state.document).toBe(originalDoc); }); }); diff --git a/editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts b/editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts index 88425204ce..44d062c9e2 100644 --- a/editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts +++ b/editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts @@ -1,32 +1,12 @@ -import reducer, { type ReducerContext } from "../../index"; -import { editor } from "@/grida-canvas"; -import grida from "@grida/schema"; +/** + * @vitest-environment node + */ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { Editor } from "@/grida-canvas/editor"; +import { createHeadlessEditor } from "@/grida-canvas/__tests__/utils"; +import { sceneNode, rectNode } from "@/grida-canvas/__tests__/utils/factories"; import color from "@grida/color"; -import type { Action } from "../../../action"; - -const geometryStub: editor.api.IDocumentGeometryQuery = { - getNodeIdsFromPoint: () => [], - getNodeIdsFromPointerEvent: () => [], - getNodeIdsFromEnvelope: () => [], - getNodeAbsoluteBoundingRect: () => ({ - x: 0, - y: 0, - width: 100, - height: 100, - }), - getNodeAbsoluteRotation: () => 0, -}; - -function createContext(): ReducerContext { - return { - geometry: geometryStub, - vector: undefined, - viewport: { width: 1000, height: 1000 }, - backend: "dom" as const, - paint_constraints: { fill: "fill", stroke: "stroke" }, - idgen: grida.id.noop.generator, - }; -} +import type grida from "@grida/schema"; function trayNode( id: string, @@ -91,50 +71,6 @@ function containerNode( }; } -function rectNode( - id: string, - name: string -): grida.program.nodes.RectangleNode { - return { - id, - type: "rectangle", - name, - active: true, - locked: false, - layout_positioning: "absolute", - layout_inset_left: 0, - layout_inset_top: 0, - layout_target_width: 50, - layout_target_height: 50, - rotation: 0, - opacity: 1, - z_index: 0, - corner_radius: 0, - stroke_width: 0, - stroke_cap: "butt", - stroke_join: "miter", - fill: { - type: "solid", - color: color.colorformats.RGBA32F.BLACK, - active: true, - }, - }; -} - -function sceneNode(): grida.program.nodes.SceneNode { - return { - type: "scene", - id: "scene", - name: "Scene", - active: true, - locked: false, - constraints: { children: "multiple" }, - guides: [], - edges: [], - background_color: null, - }; -} - /** * Creates a document with: * scene @@ -144,7 +80,7 @@ function sceneNode(): grida.program.nodes.SceneNode { * +-- tray2 * +-- container2 */ -function createDocument(): grida.program.document.Document { +function createTrayDocument(): grida.program.document.Document { return { scenes_ref: ["scene"], links: { @@ -153,12 +89,12 @@ function createDocument(): grida.program.document.Document { tray2: [], }, nodes: { - scene: sceneNode(), + scene: sceneNode("scene", "Scene"), tray1: trayNode("tray1", "Tray 1"), tray2: trayNode("tray2", "Tray 2"), container1: containerNode("container1", "Container 1"), container2: containerNode("container2", "Container 2"), - rect1: rectNode("rect1", "Rectangle 1"), + rect1: rectNode("rect1", { name: "Rectangle 1" }), }, entry_scene_id: "scene", bitmaps: {}, @@ -167,143 +103,87 @@ function createDocument(): grida.program.document.Document { }; } -function createState() { - return editor.state.init({ - editable: true, - debug: false, - document: createDocument(), - templates: {}, - }); -} +describe("Tray Move Constraints", () => { + let ed: Editor; -function dispatch( - state: editor.state.IEditorState, - action: Action, - context: ReducerContext -) { - const [next] = reducer(state, action, context); - return next; -} + beforeEach(() => { + ed = createHeadlessEditor({ document: createTrayDocument() }); + }); -describe("Tray Move Constraints", () => { - const context = createContext(); + afterEach(() => { + ed.dispose(); + }); describe("Tray parent constraint: Tray can only be child of Scene or Tray", () => { test("move tray into container — rejected", () => { - let state = createState(); - state = dispatch( - state, - { type: "mv", source: ["tray1"], target: "container2" }, - context - ); + ed.doc.mv(["tray1"], "container2"); // tray1 should still be a child of scene - expect(state.document.links["scene"]).toContain("tray1"); - expect(state.document.links["container2"] ?? []).not.toContain("tray1"); + expect(ed.state.document.links["scene"]).toContain("tray1"); + expect(ed.state.document.links["container2"] ?? []).not.toContain( + "tray1" + ); }); test("move tray into another tray — accepted", () => { - let state = createState(); - state = dispatch( - state, - { type: "mv", source: ["tray2"], target: "tray1" }, - context - ); + ed.doc.mv(["tray2"], "tray1"); // tray2 should now be a child of tray1 - expect(state.document.links["tray1"]).toContain("tray2"); - expect(state.document.links["scene"]).not.toContain("tray2"); + expect(ed.state.document.links["tray1"]).toContain("tray2"); + expect(ed.state.document.links["scene"]).not.toContain("tray2"); }); test("move tray to scene root — accepted", () => { - let state = createState(); // First move tray2 into tray1 - state = dispatch( - state, - { type: "mv", source: ["tray2"], target: "tray1" }, - context - ); - expect(state.document.links["tray1"]).toContain("tray2"); + ed.doc.mv(["tray2"], "tray1"); + expect(ed.state.document.links["tray1"]).toContain("tray2"); // Then move it back to scene root - state = dispatch( - state, - { type: "mv", source: ["tray2"], target: "scene" }, - context - ); - expect(state.document.links["scene"]).toContain("tray2"); - expect(state.document.links["tray1"]).not.toContain("tray2"); + ed.doc.mv(["tray2"], "scene"); + expect(ed.state.document.links["scene"]).toContain("tray2"); + expect(ed.state.document.links["tray1"]).not.toContain("tray2"); }); }); describe("Tray as parent: accepts any child node type", () => { test("move container into tray — accepted", () => { - let state = createState(); - state = dispatch( - state, - { type: "mv", source: ["container2"], target: "tray1" }, - context - ); - expect(state.document.links["tray1"]).toContain("container2"); - expect(state.document.links["scene"]).not.toContain("container2"); + ed.doc.mv(["container2"], "tray1"); + expect(ed.state.document.links["tray1"]).toContain("container2"); + expect(ed.state.document.links["scene"]).not.toContain("container2"); }); test("move rectangle into tray — accepted", () => { - let state = createState(); // rect1 is already in tray1, move it to tray2 - state = dispatch( - state, - { type: "mv", source: ["rect1"], target: "tray2" }, - context - ); - expect(state.document.links["tray2"]).toContain("rect1"); - expect(state.document.links["tray1"]).not.toContain("rect1"); + ed.doc.mv(["rect1"], "tray2"); + expect(ed.state.document.links["tray2"]).toContain("rect1"); + expect(ed.state.document.links["tray1"]).not.toContain("rect1"); }); }); describe("Moving nodes out of tray", () => { test("move container out of tray to scene root — accepted", () => { - let state = createState(); // container1 is in tray1, move it to scene root - state = dispatch( - state, - { type: "mv", source: ["container1"], target: "scene" }, - context - ); - expect(state.document.links["scene"]).toContain("container1"); - expect(state.document.links["tray1"]).not.toContain("container1"); + ed.doc.mv(["container1"], "scene"); + expect(ed.state.document.links["scene"]).toContain("container1"); + expect(ed.state.document.links["tray1"]).not.toContain("container1"); }); test("move container from tray to another container — accepted", () => { - let state = createState(); // container1 is in tray1, move it into container2 - state = dispatch( - state, - { type: "mv", source: ["container1"], target: "container2" }, - context - ); - expect(state.document.links["container2"]).toContain("container1"); - expect(state.document.links["tray1"]).not.toContain("container1"); + ed.doc.mv(["container1"], "container2"); + expect(ed.state.document.links["container2"]).toContain("container1"); + expect(ed.state.document.links["tray1"]).not.toContain("container1"); }); }); describe("Cycle prevention with trays", () => { test("move tray into its own child — rejected (cycle)", () => { - let state = createState(); // First nest tray2 inside tray1 - state = dispatch( - state, - { type: "mv", source: ["tray2"], target: "tray1" }, - context - ); - expect(state.document.links["tray1"]).toContain("tray2"); + ed.doc.mv(["tray2"], "tray1"); + expect(ed.state.document.links["tray1"]).toContain("tray2"); // Now try to move tray1 into tray2 — would create a cycle - state = dispatch( - state, - { type: "mv", source: ["tray1"], target: "tray2" }, - context - ); + ed.doc.mv(["tray1"], "tray2"); // tray1 should still be at scene root - expect(state.document.links["scene"]).toContain("tray1"); + expect(ed.state.document.links["scene"]).toContain("tray1"); }); }); }); From c5413e5abeb7ec081d7c03e9dbd9074f905468c9 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 6 Apr 2026 17:17:24 +0900 Subject: [PATCH 2/3] docs --- .agents/skills/cg-perf/SKILL.md | 32 +++++++++---------- crates/grida-canvas/src/runtime/scene.rs | 2 +- docs/wg/feat-2d/optimization.md | 2 +- docs/wg/feat-2d/wasm-benchmarking.md | 2 +- .../feat-2d/wasm-load-scene-optimization.md | 8 ++--- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.agents/skills/cg-perf/SKILL.md b/.agents/skills/cg-perf/SKILL.md index bfca27c447..14f3ace93c 100644 --- a/.agents/skills/cg-perf/SKILL.md +++ b/.agents/skills/cg-perf/SKILL.md @@ -123,19 +123,19 @@ reports `min/p50/p95/p99/MAX` plus per-stage breakdown and settle cost. **Scenario types in the expanded matrix:** -| Kind | Scenarios | What it tests | -| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `pan` | slow/fast × fit/zoomed | Linear back-and-forth panning | -| `circle_pan` | small/large radius × fit/zoomed | Circular trackpad gesture (unpredictable edges) | -| `zigzag` | fast (continuous) / slow (with pauses) × fit/zoomed | Diagonal reading pattern with direction changes | -| `zoom` | slow/fast × around-fit/high | Zoom oscillation at different levels | -| `pan_with_settle` | slow/fast × fit/zoomed | Pan with settle frames interleaved every 12 frames | -| `zoom_with_settle`| slow/fast × fit/high | Zoom with settle frames interleaved every 12 frames — captures cache-cold spike after settle nukes zoom cache | -| `zoom_forced_stable` | slow/fast × fit/high (BUG prefix) | Forces `stable=true` on every zoom frame — reproduces the `redraw()` bug for A/B comparison | -| `realtime` | fast/slow × fit/zoomed | **Real-time event loop simulation** with sleep, 240Hz tick thread, and settle countdown matching the native viewer | -| `frameloop` | 16/50/80/120/200/300/500ms interval | **Real FrameLoop path** — the only bench that captures stable-frame jank during panning (see below) | -| `frameloop_zoom` | 16/50/80/120/200/500ms interval | **Real FrameLoop path for zoom** — captures stable-frame intrusion during zoom gestures | -| `resize` | alternating viewport sizes | `--resize` flag. Measures `resize()` + `redraw()` cost per cycle (layout rebuild + cache invalidation + repaint) | +| Kind | Scenarios | What it tests | +| -------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `pan` | slow/fast × fit/zoomed | Linear back-and-forth panning | +| `circle_pan` | small/large radius × fit/zoomed | Circular trackpad gesture (unpredictable edges) | +| `zigzag` | fast (continuous) / slow (with pauses) × fit/zoomed | Diagonal reading pattern with direction changes | +| `zoom` | slow/fast × around-fit/high | Zoom oscillation at different levels | +| `pan_with_settle` | slow/fast × fit/zoomed | Pan with settle frames interleaved every 12 frames | +| `zoom_with_settle` | slow/fast × fit/high | Zoom with settle frames interleaved every 12 frames — captures cache-cold spike after settle nukes zoom cache | +| `zoom_forced_stable` | slow/fast × fit/high (BUG prefix) | Forces `stable=true` on every zoom frame — reproduces the `redraw()` bug for A/B comparison | +| `realtime` | fast/slow × fit/zoomed | **Real-time event loop simulation** with sleep, 240Hz tick thread, and settle countdown matching the native viewer | +| `frameloop` | 16/50/80/120/200/300/500ms interval | **Real FrameLoop path** — the only bench that captures stable-frame jank during panning (see below) | +| `frameloop_zoom` | 16/50/80/120/200/500ms interval | **Real FrameLoop path for zoom** — captures stable-frame intrusion during zoom gestures | +| `resize` | alternating viewport sizes | `--resize` flag. Measures `resize()` + `redraw()` cost per cycle (layout rebuild + cache invalidation + repaint) | **SurfaceUI overlay measurement (`--overlay`):** @@ -157,7 +157,7 @@ cargo run -p grida-dev --release -- bench-report ./fixtures/ --frames 100 --over The overlay cost is opt-in because it is a devtools feature, not user content. Overlay cost scales with visible labeled nodes — viewport culling skips off-screen labels, so zoomed-in views are nearly free. -At fit-zoom on large scenes (yrr-main, 437 labels visible), overlay +At fit-zoom on large scenes (e.g. 500 labels visible), overlay adds ~1.8ms per frame (paragraph layout dominates). At typical editing zoom, the cost drops to ~190µs or less. @@ -552,7 +552,7 @@ after content `flush()` and requires a second GPU flush. The overlay cost is dominated by Skia paragraph creation (one per visible label) — viewport culling skips off-screen labels, and style objects are hoisted out of the per-label loop. On scenes with many labeled nodes at -fit-zoom (e.g. yrr-main with 437 labels), the overlay adds ~1.8ms per +fit-zoom (e.g. 500 visible labels), the overlay adds ~1.8ms per frame. At typical editing zoom, most labels are culled and cost drops to ~190µs. Standard benchmarks exclude overlay by default — use `--overlay` to include it. If the app feels slower after adding new @@ -627,7 +627,7 @@ WASM-on-Node benchmark: # Build WASM first just --justfile crates/grida-canvas-wasm/justfile build -# Run benchmark (requires fixtures/local/perf/local/yrr-main.grida for 136k test) +# Run benchmark (requires a large .grida fixture in fixtures/local/perf/local/) cd crates/grida-canvas-wasm && npx vitest run __test__/bench-load-scene.test.ts ``` diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs index 160d1e9e39..de6daec811 100644 --- a/crates/grida-canvas/src/runtime/scene.rs +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -466,7 +466,7 @@ impl Renderer { // and stable frames — the first frame records the picture, // and the settle frame finds it immediately. // - // On yrr-main (135K nodes, 0 effects), this eliminates ~800 us + // On a 135K-node scene with 0 effects, this eliminates ~800 us // of LayerEntry clones + SkPicture recordings on every settle. let effective_key = if can_unify && entry.layer.effects_empty() { 0 diff --git a/docs/wg/feat-2d/optimization.md b/docs/wg/feat-2d/optimization.md index 2a394d0f2c..991906d8e9 100644 --- a/docs/wg/feat-2d/optimization.md +++ b/docs/wg/feat-2d/optimization.md @@ -842,7 +842,7 @@ missing the cheapest possible camera-change path. when the camera is actively changing, otherwise every interaction frame nukes the zoom cache and forces a full O(N) draw. - **Measured impact (yrr-main.grida, 136K nodes, 100 frames):** + **Measured impact (136K-node scene, 100 frames):** | Scenario | Before µs (fps) | After µs (fps) | Speedup | | -------------------- | --------------- | -------------- | --------- | diff --git a/docs/wg/feat-2d/wasm-benchmarking.md b/docs/wg/feat-2d/wasm-benchmarking.md index 55d3869a9d..ce3df0ca75 100644 --- a/docs/wg/feat-2d/wasm-benchmarking.md +++ b/docs/wg/feat-2d/wasm-benchmarking.md @@ -333,4 +333,4 @@ breakdowns without any additional instrumentation. - WASM-on-Node bench: `crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts` - Run: `cd crates/grida-canvas-wasm && npx vitest run __test__/bench-load-scene.test.ts` - Reuses build artifacts from `crates/grida-canvas-wasm/lib/bin/` -- Requires fixture: `fixtures/local/perf/local/yrr-main.grida` (136k-node scene) +- Requires a local `.grida` fixture with a large node count (e.g. 136k nodes) placed in `fixtures/local/perf/local/` diff --git a/docs/wg/feat-2d/wasm-load-scene-optimization.md b/docs/wg/feat-2d/wasm-load-scene-optimization.md index db1a202aa0..aa5030985a 100644 --- a/docs/wg/feat-2d/wasm-load-scene-optimization.md +++ b/docs/wg/feat-2d/wasm-load-scene-optimization.md @@ -8,7 +8,7 @@ format: md ## Problem -`Renderer::load_scene()` for a 136k-node Figma-imported scene (yrr-main.grida) takes ~10s in WASM vs ~800ms native — a 13× overhead far beyond the normal 2-3× WASM/native ratio. +`Renderer::load_scene()` for a 136k-node Figma-imported scene takes ~10s in WASM vs ~800ms native — a 13× overhead far beyond the normal 2-3× WASM/native ratio. ## Current Measurements (WASM-on-Node) @@ -132,7 +132,7 @@ npx vitest run __test__/bench-load-scene.test.ts Native benchmarks: ```sh -cargo run -p grida-dev --release -- load-bench fixtures/local/perf/local/yrr-main.grida --iterations 3 +cargo run -p grida-dev --release -- load-bench fixtures/local/perf/local/.grida --iterations 3 ``` Build WASM (from repo root): @@ -156,7 +156,7 @@ just --justfile crates/grida-canvas-wasm/justfile build 2. `cargo check -p cg -p grida-canvas-wasm -p grida-dev` — all crates compile 3. Native benchmark: should not regress (target: `<800ms`) 4. WASM-on-Node benchmark: geometry stage should drop from ~4s to `<1s` -5. Visual: load yrr-main in browser debug embed, verify text renders correctly and pan/zoom/settle work +5. Visual: load the fixture in browser debug embed, verify text renders correctly and pan/zoom/settle work --- @@ -175,7 +175,7 @@ just --justfile crates/grida-canvas-wasm/justfile build All 330 tests pass. No API changes. Single file modified. -### Benchmark Results (yrr-main.grida, 136K nodes) +### Benchmark Results (136K-node scene) **Native (release, 3-iteration average):** From 7be6e5364a27b3776a5cf1cf162b0223c2690191 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 6 Apr 2026 19:33:59 +0900 Subject: [PATCH 3/3] docs(canvas): add LOD property catalog and Skia cost probes (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add research artifacts for zoom-aware Level-of-Detail rendering: - LOD property reference sheet (lod-properties.md): catalogs 50+ per-node properties across 12 categories (geometry, stroke, path, effects, text, fills, clip/mask, container, render-surface, devtools) where zoom-indexed LOD decisions can reduce per-frame work. - Skia RRect vs Rect cost probe (skia_bench_rrect_vs_rect.rs): measures GPU dispatch cost across radii and scales. Key finding: on Apple M2 Metal, sub-pixel rrect radii are 0.72-0.84x the cost of drawRect — the analytic AA shader short-circuits efficiently. Manual rrect→rect collapse would regress performance on this backend. - Skia text paragraph paint cost probe (skia_bench_text_lod.rs): measures paragraph.paint() vs drawRect across font sizes. Key finding: paragraph paint is 2.4-6x more expensive than drawRect at all sizes (0.8 µs/node at small sizes, 2.1 µs at 48px). Greeking text at low zoom is a validated optimization path. - optimization.md items 51-52: design specs for subpixel LOD culling and text LOD (cull + greek) with measured impact data from the 136K-node fixture. Both designed and prototyped but not shipped. --- crates/grida-canvas/Cargo.toml | 10 + .../skia_bench/skia_bench_rrect_vs_rect.rs | 346 ++++++++++++++++++ .../skia_bench/skia_bench_text_lod.rs | 175 +++++++++ docs/wg/feat-2d/lod-properties.md | 211 +++++++++++ docs/wg/feat-2d/optimization.md | 69 ++++ 5 files changed, 811 insertions(+) create mode 100644 crates/grida-canvas/examples/skia_bench/skia_bench_rrect_vs_rect.rs create mode 100644 crates/grida-canvas/examples/skia_bench/skia_bench_text_lod.rs create mode 100644 docs/wg/feat-2d/lod-properties.md diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml index 40d5e1e313..2c582b26dd 100644 --- a/crates/grida-canvas/Cargo.toml +++ b/crates/grida-canvas/Cargo.toml @@ -117,6 +117,16 @@ name = "skia_bench_primitives" path = "examples/skia_bench/skia_bench_primitives.rs" required-features = ["native-gl-context"] +[[example]] +name = "skia_bench_rrect_vs_rect" +path = "examples/skia_bench/skia_bench_rrect_vs_rect.rs" +required-features = ["native-gl-context"] + +[[example]] +name = "skia_bench_text_lod" +path = "examples/skia_bench/skia_bench_text_lod.rs" +required-features = ["native-gl-context"] + [[example]] name = "skia_bench_effects" path = "examples/skia_bench/skia_bench_effects.rs" diff --git a/crates/grida-canvas/examples/skia_bench/skia_bench_rrect_vs_rect.rs b/crates/grida-canvas/examples/skia_bench/skia_bench_rrect_vs_rect.rs new file mode 100644 index 0000000000..b10230a226 --- /dev/null +++ b/crates/grida-canvas/examples/skia_bench/skia_bench_rrect_vs_rect.rs @@ -0,0 +1,346 @@ +//! Skia RRect vs Rect — device-space cost measurement. +//! +//! Question: does `drawRRect(r)` cost more than `drawRect` on GPU, and +//! does that cost remain present at tiny (sub-pixel) device radii? +//! +//! This directly answers whether a zoom-aware LOD policy that collapses +//! `rrect → rect` when `radius · camera_zoom < 0.5 px` would be +//! complementary to Skia's internal behavior or redundant. +//! +//! Skia's own auto-collapse (`SkRRect::isRect()`) only triggers on +//! EXACTLY-zero radii. Our theory: non-zero sub-pixel radii still take +//! the rrect shader path. This bench verifies that claim. +//! +//! ```bash +//! cargo run -p cg --example skia_bench_rrect_vs_rect --features native-gl-context --release +//! ``` + +#[cfg(feature = "native-gl-context")] +use cg::window::headless::HeadlessGpu; +use std::time::Instant; + +#[cfg(not(feature = "native-gl-context"))] +fn main() { + eprintln!("This example requires --features native-gl-context"); +} + +#[cfg(feature = "native-gl-context")] +fn flush_gpu(surface: &mut skia_safe::Surface) { + if let Some(mut ctx) = surface.recording_context() { + if let Some(mut direct) = ctx.as_direct_context() { + direct.flush_and_submit(); + } + } +} + +#[cfg(feature = "native-gl-context")] +fn main() { + let mut gpu = HeadlessGpu::new(1000, 1000).expect("GPU init"); + gpu.print_gl_info(); + println!(); + + let surface = &mut gpu.surface; + let n_iter = 300; + + println!("=== Rect vs RRect — device-space cost ==="); + println!("5000 shapes/frame, non-overlapping 100×50 grid."); + println!("Each shape is 8×8 device px. All coordinates device-space."); + println!("Corner radius varied from 0 → 4 px."); + println!(); + + let count = 5000usize; + + // Warmup (compile shaders, prime GPU) + for _ in 0..30 { + flush_gpu(surface); + bench_rects_device(surface, count, 1); + bench_rrects_device(surface, count, 1.0, 1); + flush_gpu(surface); + } + + // Baseline: drawRect + let rect_us = bench_rects_device(surface, count, n_iter); + println!( + " drawRect (baseline): {:>8} us | {:.3} us/shape", + rect_us, + rect_us as f64 / count as f64 + ); + println!(); + + println!( + "{:>12} {:>12} {:>12} {:>12} {:>14}", + "radius(dev-px)", "us/frame", "us/shape", "Δ vs rect", "rrect/rect" + ); + println!("{}", "─".repeat(76)); + + // Sub-pixel radii + for &radius in &[0.0_f32, 0.05, 0.1, 0.25, 0.49] { + let us = bench_rrects_device(surface, count, radius, n_iter); + let delta = us as i64 - rect_us as i64; + let ratio = us as f64 / rect_us as f64; + let note = if radius == 0.0 { + " (r=0 auto-fast-path)" + } else { + " ← subpixel" + }; + println!( + "{:>14.3} {:>12} {:>12.3} {:>+12} {:>13.2}x{}", + radius, + us, + us as f64 / count as f64, + delta, + ratio, + note + ); + } + + println!(); + // Near-pixel radii (rrect shader engaged) + for &radius in &[0.5, 1.0, 2.0, 4.0, 8.0] { + let us = bench_rrects_device(surface, count, radius, n_iter); + let delta = us as i64 - rect_us as i64; + let ratio = us as f64 / rect_us as f64; + println!( + "{:>14.3} {:>12} {:>12.3} {:>+12} {:>13.2}x", + radius, + us, + us as f64 / count as f64, + delta, + ratio + ); + } + + println!(); + + // Repeat with larger 32x32 shapes to see if shape size changes the pattern + println!("=== Larger 32×32 shapes (different GPU path?) ==="); + let rect_us32 = bench_rects_device_sized(surface, count, 32.0, n_iter); + println!(" drawRect(32×32): {:>8} us", rect_us32); + for &radius in &[0.0_f32, 0.25, 0.5, 1.0, 4.0, 16.0] { + let us = bench_rrects_device_sized(surface, count, 32.0, radius, n_iter); + let delta = us as i64 - rect_us32 as i64; + let ratio = us as f64 / rect_us32 as f64; + println!( + " drawRRect(32×32, r={:>5.2}):{:>8} us Δ={:>+6} ({:.2}x)", + radius, us, delta, ratio + ); + } + println!(); + + // === Part 2: Application-level projected-radius scenario === + println!("=== Application-level projected-radius scenario ==="); + println!("World radius=4.0, scale varies. Projected radius = 4·scale."); + println!("Measures what happens when an app DOES NOT collapse rrect→rect:"); + println!(); + println!( + "{:>8} {:>14} {:>12} {:>12}", + "scale", "projected r (px)", "rrect(us)", "rect(us)" + ); + println!("{}", "─".repeat(52)); + for &scale in &[1.0_f32, 0.5, 0.25, 0.1, 0.05, 0.02] { + let rrect_us = bench_rrects_scaled(surface, count, scale, 4.0, n_iter); + let rect_us = bench_rects_scaled(surface, count, scale, n_iter); + println!( + "{:>8.3} {:>14.3} {:>12} {:>12}", + scale, + 4.0 * scale, + rrect_us, + rect_us + ); + } + println!(); + + // === Part 3: Path-wrapped rrect (sanity check) === + println!("=== Skia auto-collapse verification ==="); + let rrect_zero = bench_rrects_device(surface, count, 0.0, n_iter); + let rect = bench_rects_device(surface, count, n_iter); + println!(" drawRect: {:>6} us", rect); + println!( + " drawRRect(r=0): {:>6} us (SkRRect::isRect() == true)", + rrect_zero + ); + println!( + " overhead at r=0: {:>+6} us ← fast-path kicks in", + rrect_zero as i64 - rect as i64 + ); + println!(); + println!("This is the ONLY zoom-independent collapse Skia does."); + println!("At r=0.01 (still near-zero but not exactly 0), the rrect shader runs:"); + let rrect_tiny = bench_rrects_device(surface, count, 0.01, n_iter); + println!( + " drawRRect(r=0.01): {:>6} us ← dispatches rrect shader!", + rrect_tiny + ); + println!( + " Δ vs r=0: {:>+6} us = cost of invoking rrect pipeline for invisible radius", + rrect_tiny as i64 - rrect_zero as i64 + ); +} + +#[cfg(feature = "native-gl-context")] +fn bench_rects_device(surface: &mut skia_safe::Surface, count: usize, n_iter: usize) -> u128 { + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + for i in 0..count { + let x = (i % 100) as f32 * 10.0; + let y = (i / 100) as f32 * 10.0; + canvas.draw_rect(skia_safe::Rect::from_xywh(x, y, 8.0, 8.0), &paint); + } + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} + +#[cfg(feature = "native-gl-context")] +fn bench_rrects_device( + surface: &mut skia_safe::Surface, + count: usize, + radius: f32, + n_iter: usize, +) -> u128 { + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + for i in 0..count { + let x = (i % 100) as f32 * 10.0; + let y = (i / 100) as f32 * 10.0; + let r = skia_safe::Rect::from_xywh(x, y, 8.0, 8.0); + let rrect = skia_safe::RRect::new_rect_xy(r, radius, radius); + canvas.draw_rrect(rrect, &paint); + } + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} + +#[cfg(feature = "native-gl-context")] +fn bench_rects_device_sized( + surface: &mut skia_safe::Surface, + count: usize, + size: f32, + n_iter: usize, +) -> u128 { + flush_gpu(surface); + let start = Instant::now(); + let step = (size + 2.0).max(10.0); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + let cols = (1000.0 / step) as usize; + for i in 0..count { + let x = (i % cols) as f32 * step; + let y = (i / cols) as f32 * step; + canvas.draw_rect(skia_safe::Rect::from_xywh(x, y, size, size), &paint); + } + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} + +#[cfg(feature = "native-gl-context")] +fn bench_rrects_device_sized( + surface: &mut skia_safe::Surface, + count: usize, + size: f32, + radius: f32, + n_iter: usize, +) -> u128 { + flush_gpu(surface); + let start = Instant::now(); + let step = (size + 2.0).max(10.0); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + let cols = (1000.0 / step) as usize; + for i in 0..count { + let x = (i % cols) as f32 * step; + let y = (i / cols) as f32 * step; + let r = skia_safe::Rect::from_xywh(x, y, size, size); + let rrect = skia_safe::RRect::new_rect_xy(r, radius, radius); + canvas.draw_rrect(rrect, &paint); + } + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} + +#[cfg(feature = "native-gl-context")] +fn bench_rrects_scaled( + surface: &mut skia_safe::Surface, + count: usize, + scale: f32, + world_radius: f32, + n_iter: usize, +) -> u128 { + // Keep shapes non-overlapping at every scale: step in world-space = 10/scale + let step = 10.0 / scale; + let size = 8.0 / scale; + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + canvas.save(); + canvas.scale((scale, scale)); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + for i in 0..count { + let x = (i % 100) as f32 * step; + let y = (i / 100) as f32 * step; + let r = skia_safe::Rect::from_xywh(x, y, size, size); + let rrect = skia_safe::RRect::new_rect_xy(r, world_radius, world_radius); + canvas.draw_rrect(rrect, &paint); + } + canvas.restore(); + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} + +#[cfg(feature = "native-gl-context")] +fn bench_rects_scaled( + surface: &mut skia_safe::Surface, + count: usize, + scale: f32, + n_iter: usize, +) -> u128 { + let step = 10.0 / scale; + let size = 8.0 / scale; + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + canvas.save(); + canvas.scale((scale, scale)); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb(255, 100, 150, 200)); + for i in 0..count { + let x = (i % 100) as f32 * step; + let y = (i / 100) as f32 * step; + canvas.draw_rect(skia_safe::Rect::from_xywh(x, y, size, size), &paint); + } + canvas.restore(); + flush_gpu(surface); + } + (start.elapsed() / n_iter as u32).as_micros() +} diff --git a/crates/grida-canvas/examples/skia_bench/skia_bench_text_lod.rs b/crates/grida-canvas/examples/skia_bench/skia_bench_text_lod.rs new file mode 100644 index 0000000000..a811878905 --- /dev/null +++ b/crates/grida-canvas/examples/skia_bench/skia_bench_text_lod.rs @@ -0,0 +1,175 @@ +//! Skia Text LOD — paragraph-paint vs greek-rect cost comparison. +//! +//! Measures whether replacing a paragraph paint with a single drawRect +//! ("greeking") is worth doing at low zoom. +//! +//! Scenario: N text nodes on a GPU surface. For each configuration, we +//! pre-shape a paragraph once (mirroring ParagraphCache behaviour), then +//! measure the per-frame cost of: +//! - `paragraph.paint()` — current path +//! - `drawRect` — greeking candidate +//! - `skip` — cull candidate +//! +//! The test varies: +//! - font size (in device pixels after projection) +//! - number of glyphs per paragraph +//! - number of paragraphs per frame +//! +//! ```bash +//! cargo run -p cg --example skia_bench_text_lod --features native-gl-context --release +//! ``` + +#[cfg(feature = "native-gl-context")] +use cg::window::headless::HeadlessGpu; +use std::time::Instant; + +#[cfg(not(feature = "native-gl-context"))] +fn main() { + eprintln!("This example requires --features native-gl-context"); +} + +#[cfg(feature = "native-gl-context")] +fn flush_gpu(surface: &mut skia_safe::Surface) { + if let Some(mut ctx) = surface.recording_context() { + if let Some(mut direct) = ctx.as_direct_context() { + direct.flush_and_submit(); + } + } +} + +#[cfg(feature = "native-gl-context")] +fn make_font_collection() -> skia_safe::textlayout::FontCollection { + use skia_safe::FontMgr; + let mut fc = skia_safe::textlayout::FontCollection::new(); + fc.set_default_font_manager(FontMgr::new(), None); + fc +} + +#[cfg(feature = "native-gl-context")] +fn build_paragraph( + fc: &skia_safe::textlayout::FontCollection, + text: &str, + font_size: f32, + max_width: f32, +) -> skia_safe::textlayout::Paragraph { + use skia_safe::textlayout; + let mut ps = textlayout::ParagraphStyle::new(); + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(font_size); + ts.set_color(skia_safe::Color::BLACK); + ps.set_text_style(&ts); + let mut builder = textlayout::ParagraphBuilder::new(&ps, fc); + builder.add_text(text); + let mut para = builder.build(); + para.layout(max_width); + para +} + +#[cfg(feature = "native-gl-context")] +fn main() { + let mut gpu = HeadlessGpu::new(1000, 1000).expect("GPU init"); + gpu.print_gl_info(); + println!(); + + let surface = &mut gpu.surface; + let fc = make_font_collection(); + let n_iter = 200; + + // Pre-shape paragraphs at each test font size. In real use the engine + // caches these in ParagraphCache, so re-shaping cost is NOT part of + // the per-frame measurement. + let sample_text = "The quick brown fox jumps over the lazy dog"; + + println!("=== Text paragraph.paint() vs drawRect vs skip ==="); + println!("Each test: 1000 paragraphs per frame, grid-positioned, non-overlapping."); + println!("Paragraphs pre-shaped (paint-only cost measured)."); + println!(); + + let count = 1000usize; + let font_sizes: &[f32] = &[0.25, 0.5, 1.0, 2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0, 48.0]; + + // Warmup: run a big paragraph paint to compile shaders + prime atlas + { + let para = build_paragraph(&fc, sample_text, 16.0, 300.0); + for _ in 0..20 { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + para.paint(canvas, (10.0, 10.0)); + flush_gpu(surface); + } + } + + println!( + "{:>10} {:>12} {:>12} {:>12} {:>12} {:>10}", + "font(px)", "paint(us)", "rect(us)", "skip(us)", "paint/rect", "per-node" + ); + println!("{}", "─".repeat(78)); + + for &font_size in font_sizes { + // Build paragraph once per font size — pre-shaped so paint() is measured. + let para = build_paragraph(&fc, sample_text, font_size, 300.0); + let para_h = para.height(); + let para_w = para.max_width(); + + // Test 1: N × paragraph.paint() + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + for i in 0..count { + let x = (i % 40) as f32 * 20.0; + let y = (i / 40) as f32 * 20.0; + para.paint(canvas, (x, y)); + } + flush_gpu(surface); + } + let paint_us = (start.elapsed() / n_iter as u32).as_micros(); + + // Test 2: N × drawRect (greek) + let mut paint_obj = skia_safe::Paint::default(); + paint_obj.set_color(skia_safe::Color::from_argb(180, 80, 80, 80)); + paint_obj.set_anti_alias(false); // greeking doesn't need AA + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + for i in 0..count { + let x = (i % 40) as f32 * 20.0; + let y = (i / 40) as f32 * 20.0; + canvas.draw_rect(skia_safe::Rect::from_xywh(x, y, para_w, para_h), &paint_obj); + } + flush_gpu(surface); + } + let rect_us = (start.elapsed() / n_iter as u32).as_micros(); + + // Test 3: skip (just clear, measure clear overhead alone) + flush_gpu(surface); + let start = Instant::now(); + for _ in 0..n_iter { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + flush_gpu(surface); + } + let skip_us = (start.elapsed() / n_iter as u32).as_micros(); + + let paint_net = paint_us as i64 - skip_us as i64; + let rect_net = rect_us as i64 - skip_us as i64; + let ratio = paint_net.max(0) as f64 / rect_net.max(1) as f64; + let per_node_us = paint_net as f64 / count as f64; + + println!( + "{:>10.2} {:>12} {:>12} {:>12} {:>12.2} {:>8.3}µs", + font_size, paint_us, rect_us, skip_us, ratio, per_node_us + ); + } + + println!(); + println!("Notes:"); + println!("- paint(us) = clear + 1000 × paragraph.paint() + flush"); + println!("- rect(us) = clear + 1000 × drawRect + flush"); + println!("- skip(us) = clear + flush (baseline overhead)"); + println!("- per-node = (paint - skip) / 1000 (per-node text cost)"); + println!("- paint/rect = how much cheaper greeking is (higher = bigger win)"); +} diff --git a/docs/wg/feat-2d/lod-properties.md b/docs/wg/feat-2d/lod-properties.md new file mode 100644 index 0000000000..51de6326e9 --- /dev/null +++ b/docs/wg/feat-2d/lod-properties.md @@ -0,0 +1,211 @@ +--- +title: LOD Properties — Reference Sheet +format: md +tags: + - internal + - wg + - canvas + - performance + - rendering + - lod +--- + +# LOD Properties — Reference Sheet + +A catalog of node and subtree properties where a zoom-aware Level-of-Detail +(LOD) decision can reduce per-frame work. Pairs with +**item 51 (Subpixel LOD Culling)** in `optimization.md`, which drops +entire leaves whose projected bounds collapse below a threshold. + +This document defines **what is LOD-able**, **what the decision metric +is**, and **where in the pipeline the decision applies**. It does NOT +prescribe specific thresholds or promise specific wins — both require +empirical verification per backend. + +## Principles + +1. **LOD decisions are camera-zoom-indexed.** A node's visual + significance depends on how it projects to device pixels. +2. **Two kinds of LOD:** + - **Skip work** — eliminate draw dispatches entirely (safe, portable) + - **Replace with cheaper primitive** — swap a complex draw for a + simpler one (requires per-backend validation; modern GPUs may + already short-circuit) +3. **The only trustworthy reason to implement a rule is a measured + win.** Categories that "look" cheap on paper may already be handled + by the underlying graphics backend. +4. **Threshold policy is pluggable, not hard-coded.** Per-property + thresholds live in a runtime config so they can be tuned per + backend / per fixture / per workload. + +## Notation + +- `z` — camera zoom (device pixels per world unit) +- `px(x) = x · z` — project a world-space length to device pixels +- A property is "subpixel" when its projection falls below a threshold + matching the backend's AA resolution (typically 0.5 px for coverage, + 1.0 px for structural features) + +## Pipeline Stages + +Each LOD decision applies at one of three stages: + +| Stage | Work avoided | Constraint | +| ------------------ | ----------------------------------------------- | ----------------------------------------------- | +| **Frame plan** | skip node / subtree entirely | needs zoom + bounds at plan time | +| **Picture record** | emit cheaper primitives into cached SkPicture | per-node pictures must become zoom-variant | +| **Draw time** | dynamic per-frame decision against current zoom | cheap decision; compatible with cached pictures | + +--- + +## Catalog + +### A. Geometric node / bounds + +| ID | Property | Metric | Action | +| --- | ------------------------ | ----------------------- | ---------------------------- | +| A1 | render bounds | both axes projected < ε | cull leaf ✅ item 51 | +| A2 | render bounds area | area·z² < ε² | cull leaf | +| A3 | render bounds diagonal | diag·z < ε | cull leaf | +| A4 | stroke-only contribution | stroke_w·z < ε | drop stroke paint, keep fill | +| A5 | subtree cumulative area | Σ child area·z² < ε² | cull subtree | + +### B. Corner & rounding + +| ID | Property | Metric | Action | +| --- | -------------------- | ----------- | --------------------------- | +| B1 | corner radius (rect) | r·z < ε | RRect → Rect | +| B2 | corner radius (path) | r·z < ε | drop corner arcs → polyline | +| B3 | stroke join miter | miter·z < ε | force bevel fallback | + +### C. Stroke & outline + +| ID | Property | Metric | Action | +| --- | ------------------------ | -------------- | ------------------------------ | +| C1 | stroke width (thin) | width·z < ε | skip stroke draw | +| C2 | stroke width (hairline) | width·z ≈ 1 px | clamp to width=0 hairline path | +| C3 | dash segment length | dash·z < ε | replace with solid stroke | +| C4 | dash gap length | gap·z < ε | replace with solid stroke | +| C5 | variable-width amplitude | amp·z < ε | collapse to constant stroke | +| C6 | marker size | marker·z < ε | omit marker | + +### D. Path / vector complexity + +| ID | Property | Metric | Action | +| --- | ---------------------- | --------------- | ----------------------------------- | +| D1 | segment chord length | chord·z < ε | drop consecutive near-coincident pt | +| D2 | bezier flattening tol | tolerance = 1/z | coarser curve tessellation | +| D3 | sub-path bbox area | bbox·z² < ε² | drop sub-path | +| D4 | near-coincident points | d·z < ε | merge points | + +### E. Effects (save_layer / filter avoidance) + +| ID | Property | Metric | Action | +| --- | ----------------------- | ---------------- | -------------------- | +| E1 | drop-shadow blur radius | r·z < ε | skip shadow | +| E2 | drop-shadow offset | \|offset\|·z < ε | fold color into fill | +| E3 | inner-shadow radius | r·z < ε | skip | +| E4 | layer blur sigma | σ·z < ε | skip blur | +| E5 | backdrop blur sigma | σ·z < ε | skip backdrop blur | +| E6 | glass displacement | d·z < ε | skip | +| E7 | noise grain scale | grain·z < ε | skip | + +### F. Opacity & blend + +| ID | Property | Metric | Action | +| --- | --------------------- | -------------------- | ------------------ | +| F1 | alpha near zero | opacity < 1/255 | cull node | +| F2 | opacity × area | α·w·h·z² < ε | cull node | +| F3 | blend on tiny subtree | subtree area·z² < ε² | force Normal blend | + +### G. Fills + +| ID | Property | Metric | Action | +| --- | ----------------------- | ------------------ | -------------------- | +| G1 | gradient projected span | span·z < ε | averaged solid | +| G2 | gradient stop density | stops > pixel span | collapse to average | +| G3 | image fill size | img_display_px < ε | center-pixel solid | +| G4 | pattern tile size | tile·z < ε | tile-averaged solid | +| G5 | occluded paint | opaque paint above | skip occluded paints | + +### H. Text + +| ID | Property | Metric | Action | +| --- | -------------------- | ------------------------- | ------------------------------ | +| H1 | font size (cull) | font·z < ε_cull | skip text entirely ✅ item 52 | +| H2 | font size (greek) | ε_cull ≤ font·z < ε_greek | render as SkRect(s) ✅ item 52 | +| H3 | line height | lh·z < ε | collapse to thin rect | +| H4 | glyph advance | adv·z < ε | merge adjacent glyphs | +| H5 | attributed run span | run·z < ε | merge runs | +| H6 | decoration thickness | thickness·z < ε | skip decoration | +| H7 | text-shadow blur | r·z < ε | skip | + +### I. Clip & mask + +| ID | Property | Metric | Action | +| --- | --------------- | -------------------- | ---------------------- | +| I1 | clip path area | bbox·z² < ε² | drop clipped subtree | +| I2 | clip complexity | many segments, low z | replace with bbox clip | +| I3 | mask area | bbox·z² < ε² | drop masked subtree | + +### J. Container / subtree + +| ID | Property | Metric | Action | +| --- | ---------------------------- | -------------------- | -------------------------- | +| J1 | subtree cumulative area | Σ children·z² < ε² | rasterize once as snapshot | +| J2 | container vs sparse children | children « container | skip container paint | +| J3 | nested container depth | depth > N at low z | flatten subtree to image | + +### K. Render-surface backing + +| ID | Property | Metric | Action | +| --- | -------------------------- | ------------------ | ---------------------------- | +| K1 | surface backing resolution | bounds·z | allocate at projected size | +| K2 | filter quality | surface_px small | nearest sampling | +| K3 | compositor promotion | cost estimate at z | don't promote if blit ≥ live | + +### L. Devtools overlays + +| ID | Property | Metric | Action | +| --- | ----------------- | ------------------ | -------------- | +| L1 | frame title label | node_w·z < label_w | hide label | +| L2 | selection handles | node_area·z² < ε² | hide handles | +| L3 | hit badges | density at z | cluster badges | + +--- + +## Verification + +Each property must be verified before implementation. Two checks: + +1. **Skia cost probe** — measure the raw per-primitive cost of the + operation to be avoided OR of the replacement primitive. If the + backend already short-circuits the condition, the LOD rule is moot + or regressive. See `examples/skia_bench/*` for the probe pattern. +2. **Scene-level bench-report diff** — run with/without the LOD rule + across a diverse fixture set, compare per-stage timings. + +Two independent sources of possible redundancy: + +- Skia's existing fast paths (e.g. `SkRRect::isRect()` for r=0) +- GPU driver's analytic-coverage shaders that early-exit on sub-pixel + inputs (varies per backend — Metal, Ganesh GL, Graphite, WebGL, …) + +Rules that **skip work entirely** (A, E, H1/H2, F1, G5) are generally +safe to implement without per-backend validation: they remove draw +dispatches the backend would otherwise execute. + +Rules that **replace with a cheaper primitive** (B, C, D, G1–G4) need +per-backend measurement because modern analytic-AA shaders may already +handle the sub-pixel case efficiently. + +## Applied Findings + +Findings are tracked inline in `optimization.md` (numbered items) and +in per-property verification notes alongside their benchmarks. + +- **Item 51 (A1)** — implemented. Subpixel leaf-bounds culling. +- **Item 52 (H1)** — implemented. Text font-size-below-threshold cull. +- **B1 (RRect → Rect)** — measured via `skia_bench_rrect_vs_rect`. + Needs per-backend decision; on some backends the analytic rrect + shader is already cheaper than `drawRect` at sub-pixel radii. diff --git a/docs/wg/feat-2d/optimization.md b/docs/wg/feat-2d/optimization.md index 991906d8e9..72caac7f85 100644 --- a/docs/wg/feat-2d/optimization.md +++ b/docs/wg/feat-2d/optimization.md @@ -1327,6 +1327,75 @@ expensive full redraws. bounded by the largest bucket ratio (~±12.5%) and is imperceptible on in-flight gestures for static content. +## LOD (Level-of-Detail) at Low Zoom + +The following items describe zoom-aware LOD strategies for reducing +per-frame work when the camera is zoomed out. They are **designed and +measured** but not yet shipped — see `docs/wg/feat-2d/lod-properties.md` +for the full property catalog across all node types, and the Skia cost +probes in `examples/skia_bench/` for per-primitive validation data. + +### Key validation findings + +- **RRect → Rect collapse (B1):** On Apple M2 Metal, Skia's analytic + rrect shader is **faster** than `drawRect` at sub-pixel radii + (0.72–0.84× rect cost). The replacement would regress performance. + Needs per-backend re-measurement before implementing. + Probe: `examples/skia_bench/skia_bench_rrect_vs_rect.rs`. + +- **Text paragraph.paint() cost:** 2.4–6× more expensive than a single + `drawRect` across all font sizes (0.8 µs/node at 0.25–12 px, + 2.1 µs/node at 48 px). Greeking or culling text is a clear win. + Probe: `examples/skia_bench/skia_bench_text_lod.rs`. + +- **Principle: "skip work" LOD rules are safe; "replace with cheaper + primitive" rules need per-backend validation.** Modern analytic-AA + GPU shaders may already handle sub-pixel inputs efficiently. + +52. **Subpixel LOD Culling** (A1) + + Drop leaf nodes from the frame plan when both projected dimensions + fall below a threshold (e.g. 0.5 px). At fit-zoom on the 136K-node + fixture, ~38% of visible leaves have both dimensions below 0.5 px + at zoom 0.02. Culling them reduces `draw_us` by 6–18% and GPU + `mid_flush_us` by up to 24%. + + Decision: `w·z < ε && h·z < ε` — both axes must be subpixel. + Thin shapes (large in one axis) survive. Gated by `zoom < 1.0`. + + Mirrors Chromium's `MinimumContentsScale` (`cc/layers/ + picture_layer_impl.cc`). + + Design: filter `indices` in `Renderer::frame()` after R-tree + query, using per-layer bounds stored in a parallel + `Vec>` for O(1) lookup. + +53. **Text LOD (H1 cull + H2 greek)** + + Two-stage policy driven by projected font size (`font_max · z`). + + - **H1 cull** (`font·z < 1 px`): remove text layer from frame + plan. Glyphs at this size cannot render a readable shape. + - **H2 greek** (`1 ≤ font·z < 6 px`): at draw time, replace the + paragraph paint with one `drawRect` per visual line, using line + positions from the cached `ParagraphCache` entry. Bars shrink + toward x-height, respect alignment and ragged edges, capped at + 12 bars per layer to bound dispatch cost. Preserves text shape + during zoom without per-glyph GPU work. + + Pure culling makes text pop in/out during zoom — jarring. Greeking + preserves the visual footprint. Combined with item 52, measured + 11–17% total frame-time reduction on the 136K fixture at fit-zoom. + + Thresholds match standard editor greek bands (Figma ~4–6 px). + + Design: H1 filter in `frame()` using per-layer max font size + stored in `Vec>`. H2 greek dispatch in + `Painter::draw_render_commands()` via a `TextGreekPolicy` that + carries `zoom` and `greek_threshold_px`. Color sampled from the + first solid fill; fallback to a single bounds-rect when paragraph + metrics aren't cached. + --- This list is designed to evolve the renderer from single-threaded mode to