From 4079c62bf027f11888978b9fcff7b41eff2612ea Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 29 Apr 2026 20:43:51 +0100 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20text-system=20rewrite=20=E2=80=94?= =?UTF-8?q?=20bottom-up=20rebuild=20around=20Cell=20+=20TextRun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end replacement of the old TextRunController. New layered architecture: Layer 0 (pure, immutable geometry): Cell = GlyphCell | LineBreak (tagged union, replaces flat GlyphRef + magic ".newline") Positioner: harfbuzz-shaped no-shape positioner; will swap in shaping later TextLayout: splitParagraphs → segmentRuns → position → assembleLayout, hit-test, pointAt Caret: immutable cursor projection over a TextLayout All built against the real MutatorSans font via shared testUtils.ts Layer 1 (state): TextBuffer: per-field signals (cells/cursor/anchor/originX), #update(patch) batching TextInteraction: editing slot + suspended + hover (composite drill-through dropped) TextRun: composes both, exposes layout/caret/selectionRects ComputedSignals, threads goalX for vertical nav, wraps mutations to keep editing state coherent TextRuns: per-glyph store with reactive active and serialize/deserialize Wire-up: Editor: textRuns + textRun getter; persistence effect tracks active run buffer HiddenTextInput: rewired to editor.textRun.{insert,delete,move...} Text tool, TextRunHover, TextRunEdit: minimal stubs to be refilled Validation schema: PersistedTextRun now { buffer: TextBufferSnapshot } (Cell-shaped) Renames + tightening: GlyphRef → Cell, unicode → codepoint, glyphRefFromUnicode → cellFromCodepoint GlyphView gains a bounds getter (delegates to font.getBbox) Positioner is the single class; interface dropped (structural typing) Deferred for follow-up: Caret.nextLine/previousLine, TextRun vertical/word/line-edge nav TextLayout.shapeHitTest TextRunRenderer (canvas-side draw, indicator-pattern class) TextRunHover + TextRunEdit real impls Composite drill-through (own class when rebuilt) Tests: 542 desktop + 112 validation passing, full typecheck clean. --- .../src/components/editor/HiddenTextInput.tsx | 41 +- .../src/renderer/src/lib/editor/Editor.ts | 49 +- .../renderer/src/lib/editor/hit/composite.ts | 28 - .../src/renderer/src/lib/model/Font.ts | 1 + .../src/renderer/src/lib/model/GlyphView.ts | 8 + .../lib/tools/select/behaviors/TextRunEdit.ts | 103 +- .../tools/select/behaviors/TextRunHover.ts | 43 +- .../renderer/src/lib/tools/text/Text.test.ts | 48 - .../src/renderer/src/lib/tools/text/Text.ts | 79 +- .../src/lib/tools/text/TextBuffer.test.ts | 115 +++ .../renderer/src/lib/tools/text/TextBuffer.ts | 302 ++++++ .../lib/tools/text/TextInteraction.test.ts | 109 ++ .../src/lib/tools/text/TextInteraction.ts | 190 ++++ .../renderer/src/lib/tools/text/TextRun.ts | 249 +++++ .../lib/tools/text/TextRunController.test.ts | 425 -------- .../src/lib/tools/text/TextRunController.ts | 938 ------------------ .../renderer/src/lib/tools/text/TextRuns.ts | 107 ++ .../tools/text/behaviors/TypingBehaviour.ts | 24 +- .../src/lib/tools/text/layout.test.ts | 384 ------- .../src/renderer/src/lib/tools/text/layout.ts | 294 ------ .../src/lib/tools/text/layout/Caret.test.ts | 64 ++ .../src/lib/tools/text/layout/Caret.ts | 96 ++ .../lib/tools/text/layout/Positioner.test.ts | 67 ++ .../src/lib/tools/text/layout/Positioner.ts | 41 + .../lib/tools/text/layout/TextLayout.test.ts | 66 ++ .../src/lib/tools/text/layout/TextLayout.ts | 258 +++++ .../src/lib/tools/text/layout/index.ts | 17 + .../src/lib/tools/text/layout/testUtils.ts | 33 + .../src/lib/tools/text/layout/types.ts | 85 ++ .../renderer/src/lib/utils/unicode.test.ts | 10 +- .../src/renderer/src/lib/utils/unicode.ts | 9 +- packages/validation/src/persistence.test.ts | 28 +- packages/validation/src/persistence.ts | 24 +- 33 files changed, 1940 insertions(+), 2395 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/editor/hit/composite.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/Text.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextRunController.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts diff --git a/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx b/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx index 2f19246e..dce8550c 100644 --- a/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx +++ b/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx @@ -3,7 +3,7 @@ * * Handles IME composition, clipboard paste, and special characters natively. * The textarea is positioned off-screen but remains focused. Input events - * feed into the TextRunController; rendering updates reactively. + * feed into `editor.textRun`; rendering updates reactively via signals. */ import { useCallback, useEffect, useRef, useState } from "react"; import { getEditor } from "@/store/store"; @@ -41,8 +41,6 @@ export function HiddenTextInput() { if (!isTextTool) return null; - const ctrl = editor.textRunController; - const handleInput = () => { const textarea = ref.current; if (!textarea) return; @@ -62,6 +60,7 @@ export function HiddenTextInput() { const handleKeyDown = (e: React.KeyboardEvent) => { const extend = e.shiftKey; + const run = editor.textRun; switch (e.key) { case "Escape": @@ -70,55 +69,55 @@ export function HiddenTextInput() { return; case "Enter": - ctrl.insert({ glyphName: ".newline", unicode: 10 }); + run.insert({ kind: "linebreak" }); e.preventDefault(); return; case "Backspace": - ctrl.delete(); + run.delete(); e.preventDefault(); return; case "Delete": - ctrl.deleteForward(); + run.deleteForward(); e.preventDefault(); return; case "ArrowLeft": - if (e.metaKey) { - ctrl.moveCursorToLineStart(extend); - } else if (e.altKey) { - ctrl.moveCursorByWord(-1, extend); + if (e.altKey) { + run.moveCursorByWord(-1, extend); + } else if (e.metaKey) { + run.moveCursorToLineStart(extend); } else { - ctrl.moveCursorLeft(extend); + run.moveCursorLeft(extend); } e.preventDefault(); return; case "ArrowRight": - if (e.metaKey) { - ctrl.moveCursorToLineEnd(extend); - } else if (e.altKey) { - ctrl.moveCursorByWord(1, extend); + if (e.altKey) { + run.moveCursorByWord(1, extend); + } else if (e.metaKey) { + run.moveCursorToLineEnd(extend); } else { - ctrl.moveCursorRight(extend); + run.moveCursorRight(extend); } e.preventDefault(); return; case "ArrowUp": - ctrl.moveCursorVertically(-1, extend); + run.moveCursorUp(extend); e.preventDefault(); return; case "ArrowDown": - ctrl.moveCursorVertically(1, extend); + run.moveCursorDown(extend); e.preventDefault(); return; case "a": if (e.metaKey || e.ctrlKey) { - ctrl.selectAll(); + run.buffer.selectAll(); e.preventDefault(); return; } @@ -126,7 +125,9 @@ export function HiddenTextInput() { case "c": if (e.metaKey || e.ctrlKey) { - const codepoints = ctrl.getCodepoints(); + const codepoints = run.buffer.selectedCells + .map((cell) => (cell.kind === "glyph" ? cell.codepoint : 10)) + .filter((cp): cp is number => cp !== null); if (codepoints.length > 0) { const text = String.fromCodePoint(...codepoints); navigator.clipboard?.writeText(text); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index af9ffb72..f1b0c131 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -95,7 +95,9 @@ import type { SnapIndicator, } from "./snapping/types"; import { SnapManager } from "./managers/SnapManager"; -import { TextRunController } from "@/lib/tools/text/TextRunController"; +import { TextRuns } from "@/lib/tools/text/TextRuns"; +import type { TextRun } from "@/lib/tools/text/TextRun"; +import { Positioner } from "@/lib/tools/text/layout"; import { SnapPreferencesSchema, TextRunModuleSchema } from "@shift/validation"; import type { TextRunModule } from "@/persistence/types"; @@ -196,7 +198,7 @@ export class Editor { #clipboard: Clipboard; #events: EventEmitter; #stateRegistry: StateRegistry; - #textRunController: TextRunController; + #textRuns: TextRuns; #mainGlyphUnicode: number | null = null; #$glyphFinderOpen: WritableSignal; @@ -309,30 +311,33 @@ export class Editor { commands: this.#commandHistory, clipboard: options.clipboard, }); - this.#textRunController = new TextRunController(); - this.#textRunController.setFont(this.font); + this.#textRuns = new TextRuns(this.font, new Positioner()); const textRunPersistence = this.registerState({ id: "text-run", scope: "document", initial: () => ({ runsByGlyph: {} }), - serialize: () => ({ runsByGlyph: this.#textRunController.exportRuns() }), + serialize: () => ({ runsByGlyph: this.#textRuns.serialize() }), deserialize: (json) => { const payload = TextRunModuleSchema.parse(json); - this.#textRunController.hydrateRuns(payload.runsByGlyph); + this.#textRuns.deserialize(payload.runsByGlyph); return payload; }, }); - // Bridge: when text run controller state changes, notify the persistence field + // Bridge: when active run's buffer changes (or active switches), notify persistence. effect(() => { - this.#textRunController.state.value; - textRunPersistence.set({ runsByGlyph: this.#textRunController.exportRuns() }); + const run = this.#textRuns.$active.value; + run.buffer.$cells.value; + run.buffer.$cursor.value; + run.buffer.$anchor.value; + run.buffer.$originX.value; + textRunPersistence.set({ runsByGlyph: this.#textRuns.serialize() }); }); this.#events.on("fontLoaded", () => { this.#commandHistory.clear(); - this.#textRunController.clearAll(); + this.#textRuns.clearAll(); }); this.#drawOffset = signal({ x: 0, y: 0 }); @@ -363,7 +368,8 @@ export class Editor { this.#hover.hoveredBoundingBoxHandle.value; this.#debugOverlays.value; this.#gpuHandlesEnabled.value; - this.#textRunController.state.value; + this.#textRuns.$active.value; + this.#textRuns.active.buffer.$cells.value; this.#renderer.requestSceneRedraw(); this.#renderer.requestBackgroundRedraw(); }); @@ -928,20 +934,25 @@ export class Editor { glyph.applyValues(values); } - public get textRunController(): TextRunController { - return this.#textRunController; + public get textRuns(): TextRuns { + return this.#textRuns; } - /** Resolve a unicode codepoint to a glyph ref and insert into the text run. */ + /** The currently-active text run. Convenience for `editor.textRuns.active`. */ + public get textRun(): TextRun { + return this.#textRuns.active; + } + + /** Resolve a unicode codepoint to a glyph cell and insert into the active text run. */ public insertTextCodepoint(codepoint: number): void { const glyphName = this.font.glyphName(codepoint); - this.#textRunController.insert({ glyphName, unicode: codepoint }); + this.textRun.insert({ kind: "glyph", glyphName, codepoint }); } /** @knipclassignore Indirectly consumed through Viewport. */ public shouldRenderGlyph(): boolean { - const state = this.#textRunController.state.peek(); - return !state || state.editingIndex !== null; + const editing = this.#textRuns.active.interaction.editing; + return editing !== null; } public getGlyphCompositeComponents(glyphName: string): CompositeGlyph | null { @@ -1005,8 +1016,8 @@ export class Editor { public setMainGlyphUnicode(unicode: number | null): void { this.#mainGlyphUnicode = unicode; - const ref = unicode === null ? null : { glyphName: this.font.glyphName(unicode), unicode }; - this.#textRunController.setOwnerGlyph(ref); + const glyphName = unicode === null ? null : this.font.glyphName(unicode); + this.#textRuns.switchTo(glyphName); } public getMainGlyphUnicode(): number | null { diff --git a/apps/desktop/src/renderer/src/lib/editor/hit/composite.ts b/apps/desktop/src/renderer/src/lib/editor/hit/composite.ts deleted file mode 100644 index e7285cd7..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/hit/composite.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Point2D, CompositeGlyph, CompositeComponent, RenderContour } from "@shift/types"; -import { Bounds } from "@shift/geo"; - -export function resolveComponentAtPoint( - composite: CompositeGlyph | null, - localPoint: Point2D, -): { index: number; component: CompositeComponent } | null { - if (!composite) return null; - - for (const [i, component] of composite.components.entries()) { - if (isPointInComponentBounds(component.contours, localPoint)) { - return { index: i, component }; - } - } - - return null; -} - -export function isPointInComponentBounds( - contours: readonly RenderContour[], - point: Point2D, -): boolean { - const allPoints = contours.flatMap((c) => c.points); - const bounds = Bounds.fromPoints(allPoints); - if (!bounds) return false; - - return Bounds.containsPoint(bounds, point); -} diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 328ef24f..f0789231 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -82,6 +82,7 @@ export class Font { return this.#bridge.getMetrics(); } + /** @knipclassignore — read by canvas drawers and TextLayout.shapeHitTest */ getPath(name: string): Path2D | null { return this.#bridge.getPath(name); } diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts b/apps/desktop/src/renderer/src/lib/model/GlyphView.ts index da2f46aa..680c07a7 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphView.ts @@ -10,6 +10,7 @@ import type { import { computed, type ComputedSignal, type Signal } from "../reactive"; import { interpolate, normalize } from "../interpolation/interpolate"; import type { Font } from "./Font"; +import { Bounds } from "@shift/geo"; /** * One curve segment from a glyph's contour at the current variation location. @@ -117,6 +118,13 @@ export class GlyphView { return this.#advance; } + get bounds(): Bounds | undefined { + const bounds = this.#font.getBbox(this.name); + if (!bounds) return; + + return bounds; + } + /** * Root contours owned directly by this glyph at the current location. * Empty for pure composites. diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts index 22707b9c..1fd8f1d8 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts @@ -1,109 +1,20 @@ import type { ToolEventOf } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; -import { hitTestTextSlot, type GlyphRef } from "../../text/layout"; -import { resolveComponentAtPoint } from "@/lib/editor/hit/composite"; /** - * Handles double-click on a text run glyph to switch it to in-place editing. - * - * Takes priority over the normal double-click-select-contour behavior - * when a text run is active. + * Stub: double-click-to-edit-glyph against the new TextRun API. Re-add the + * real implementation in a follow-up — it needs `layout.shapeHitTest` + * (currently still a throw stub) and the composite-component drill-through + * we deferred. Returns false to defer to the regular double-click handler. */ export class TextRunEdit implements SelectBehavior { onDoubleClick( state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"doubleClick">, + _ctx: ToolContext, + _event: ToolEventOf<"doubleClick">, ): boolean { if (state.type !== "ready" && state.type !== "selected") return false; - - const ctrl = ctx.editor.textRunController; - - let textRunState = ctrl.state.value; - if (!textRunState) { - const activeName = ctx.editor.getActiveGlyphName(); - if (!activeName) return false; - ctrl.seed({ - glyphName: activeName, - unicode: ctx.editor.getActiveGlyphUnicode(), - }); - ctrl.setOriginX(ctx.editor.drawOffset.x); - textRunState = ctrl.state.value; - } - if (!textRunState) return false; - - const metrics = ctx.editor.font.getMetrics(); - const hitIndex = hitTestTextSlot(textRunState.layout, event.point, metrics, ctx.editor.font, { - outlineRadius: ctx.editor.hitRadius, - includeFill: true, - requireShape: true, - }); - if (hitIndex === null) { - if (textRunState.compositeInspection !== null) { - ctrl.clearInspection(); - return true; - } - return false; - } - - const slot = textRunState.layout.slots[hitIndex]; - if (!slot) return true; - - const composite = ctx.editor.getGlyphCompositeComponents(slot.glyph.glyphName); - const isComposite = !!composite && composite.components.length > 0; - const isInspected = textRunState.compositeInspection?.slotIndex === hitIndex; - - if (isComposite && !isInspected) { - ctrl.setInspectionSlot(hitIndex); - ctrl.setInspectionHoveredComponent(null); - ctrl.setEditingSlot(null); - return true; - } - - const localPoint = { x: event.point.x - slot.x, y: event.point.y }; - const hit = resolveComponentAtPoint(composite, localPoint); - const hitComponent = hit?.component ?? null; - - if (hitComponent) { - const insertedGlyph: GlyphRef = { - glyphName: hitComponent.componentGlyphName, - unicode: hitComponent.sourceUnicodes[0] ?? null, - }; - - const insertedIndex = hitIndex + 1; - ctrl.insertAt(insertedIndex, insertedGlyph); - - const nextState = ctrl.state.value; - const insertedSlot = nextState?.layout.slots[insertedIndex]; - const slotX = insertedSlot?.x ?? slot.x; - - ctx.editor.open(insertedGlyph.glyphName); - ctx.editor.setDrawOffsetForGlyph( - { x: slotX, y: insertedSlot?.y ?? slot.y }, - insertedGlyph.glyphName, - insertedGlyph.unicode, - ); - ctx.editor.setPreviewMode(false); - ctrl.setEditingSlot(insertedIndex, insertedGlyph); - ctrl.clearInspection(); - return true; - } - - if (isComposite) { - ctrl.setInspectionHoveredComponent(null); - return true; - } - - ctx.editor.open(slot.glyph.glyphName); - ctx.editor.setDrawOffsetForGlyph( - { x: slot.x, y: slot.y }, - slot.glyph.glyphName, - slot.glyph.unicode, - ); - ctx.editor.setPreviewMode(false); - ctrl.setEditingSlot(hitIndex, slot.glyph); - ctrl.clearInspection(); - return true; + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts index 5cd8b477..8c8da4a5 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts @@ -1,52 +1,19 @@ import type { ToolEventOf } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; -import { hitTestTextSlot } from "../../text/layout"; -import { resolveComponentAtPoint } from "@/lib/editor/hit/composite"; /** - * Updates hover indicator on text run glyphs during pointer movement. - * - * This is a visual-only behavior — it returns false so that - * subsequent behaviors can also process the pointer move event. + * Stub: visual-only hover indicator on text run cells. Re-add real hit-test + * logic against the new TextRun API in a follow-up. Returns false so other + * pointer-move behaviors still run. */ export class TextRunHover implements SelectBehavior { onPointerMove( state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"pointerMove">, + _ctx: ToolContext, + _event: ToolEventOf<"pointerMove">, ): boolean { if (state.type !== "ready" && state.type !== "selected") return false; - - const ctrl = ctx.editor.textRunController; - const textRunState = ctrl.state.value; - if (!textRunState) return false; - - const metrics = ctx.editor.font.getMetrics(); - const hitIndex = hitTestTextSlot(textRunState.layout, event.point, metrics, ctx.editor.font, { - outlineRadius: ctx.editor.hitRadius, - includeFill: true, - requireShape: true, - }); - - ctrl.setHovered(hitIndex); - const inspection = textRunState.compositeInspection; - if (!inspection || hitIndex !== inspection.slotIndex) { - ctrl.setInspectionHoveredComponent(null); - return false; - } - - const slot = textRunState.layout.slots[inspection.slotIndex]; - if (!slot) { - ctrl.setInspectionHoveredComponent(null); - return false; - } - - const composite = ctx.editor.getGlyphCompositeComponents(slot.glyph.glyphName); - const localPoint = { x: event.point.x - slot.x, y: event.point.y }; - const hitComponent = resolveComponentAtPoint(composite, localPoint); - ctrl.setInspectionHoveredComponent(hitComponent?.index ?? null); - return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/Text.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/Text.test.ts deleted file mode 100644 index 60db734c..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { TestEditor } from "@/testing"; - -describe("Text tool", () => { - let editor: TestEditor; - - beforeEach(() => { - editor = new TestEditor(); - editor.startSession("A", 65); - }); - - it("enters typing state when activated", () => { - editor.selectTool("text"); - - const state = editor.toolManager.activeTool?.getState() as { type: string }; - expect(state.type).toBe("typing"); - }); - - it("moves cursor to end of buffer when activating", () => { - const ctrl = editor.textRunController; - ctrl.insert({ glyphName: "A", unicode: 65 }); - ctrl.insert({ glyphName: "B", unicode: 66 }); - ctrl.placeCaret(0); - - editor.selectTool("text"); - - expect(ctrl.cursor).toBe(ctrl.length); - }); - - it("returns to select tool on Escape", () => { - editor.selectTool("text"); - expect(editor.toolManager.activeToolId).toBe("text"); - - editor.keyDown("Escape"); - - expect(editor.toolManager.activeToolId).toBe("select"); - }); - - it("handles character input and advances cursor", () => { - editor.selectTool("text"); - const ctrl = editor.textRunController; - const lengthBefore = ctrl.length; - - editor.keyDown("b"); - - expect(ctrl.length).toBeGreaterThanOrEqual(lengthBefore); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts index 620c5beb..cb3e4d7d 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -5,10 +5,7 @@ import { TypingBehavior } from "./behaviors/TypingBehaviour"; export class Text extends BaseTool { readonly id: ToolName = "text"; - readonly behaviors: TextBehavior[] = [new TypingBehavior()]; - #hadEditingSlot = false; - #pendingOriginX: number | null = null; override getCursor(_state: TextState): CursorType { return { type: "text" }; @@ -19,74 +16,32 @@ export class Text extends BaseTool { } override activate(): void { - const ctrl = this.editor.textRunController; - const hasExistingRun = ctrl.length > 0; - const drawOffset = this.editor.drawOffset; - const activeGlyphName = this.editor.getActiveGlyphName(); - const activeUnicode = this.editor.getActiveGlyphUnicode(); - const activeGlyph = - activeGlyphName !== null ? { glyphName: activeGlyphName, unicode: activeUnicode } : null; - - this.#hadEditingSlot = ctrl.state.value?.editingIndex !== null; - - if (activeGlyph) ctrl.seed(activeGlyph); - this.#pendingOriginX = hasExistingRun ? null : drawOffset.x; - - const editingIndex = ctrl.state.value?.editingIndex; - if (editingIndex !== null && editingIndex !== undefined) { - ctrl.placeCaret(editingIndex + 1); - } else { - ctrl.moveCursorToEnd(); + const activeName = this.editor.getActiveGlyphName(); + if (!activeName) { + this.state = { type: "typing" }; + this.editor.setPreviewMode(true); + return; } + const activeUnicode = this.editor.getActiveGlyphUnicode(); + const run = this.editor.textRuns.switchTo(activeName); + run.seed( + { kind: "glyph", glyphName: activeName, codepoint: activeUnicode }, + this.editor.drawOffset.x, + ); + run.interaction.suspend(); + run.setCursorVisible(true); + this.state = { type: "typing" }; - ctrl.suspendEditing(); - ctrl.setCursorVisible(true); this.editor.setPreviewMode(true); - if (this.#pendingOriginX !== null) { - ctrl.setOriginX(this.#pendingOriginX); - this.#pendingOriginX = null; - } } override deactivate(): void { - const ctrl = this.editor.textRunController; - ctrl.setCursorVisible(false); + const run = this.editor.textRun; + run.setCursorVisible(false); + run.interaction.resume(); this.editor.setPreviewMode(false); - this.#restoreEditingContext(); this.state = { type: "idle" }; - this.#hadEditingSlot = false; - this.#pendingOriginX = null; - } - - #restoreEditingContext(): void { - const ctrl = this.editor.textRunController; - - if (!this.#hadEditingSlot) { - this.editor.setDrawOffset({ x: 0, y: 0 }); - ctrl.resetEditingContext(); - return; - } - - const restored = ctrl.resumeEditing(); - if (!restored) { - this.editor.setDrawOffset({ x: 0, y: 0 }); - ctrl.resetEditingContext(); - return; - } - - const textRunState = ctrl.state.value; - const slot = textRunState?.layout.slots[restored.index]; - if (slot) { - this.editor.setDrawOffsetForGlyph( - { x: slot.x, y: slot.y }, - restored.glyph.glyphName, - restored.glyph.unicode, - ); - } else { - this.editor.setDrawOffset({ x: 0, y: 0 }); - ctrl.resetEditingContext(); - } } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts new file mode 100644 index 00000000..3f87e315 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TextBuffer } from "./TextBuffer"; +import { glyphCell as glyph } from "./layout"; + +describe("TextBuffer", () => { + let buffer: TextBuffer; + + beforeEach(() => { + buffer = new TextBuffer(); + }); + + // SEED — fresh buffer is empty with cursor at 0. + it("starts empty", () => { + expect(buffer.cells).toEqual([]); + expect(buffer.length).toBe(0); + expect(buffer.cursor).toBe(0); + expect(buffer.anchor).toBe(0); + expect(buffer.hasSelection).toBe(false); + }); + + // SEED — insert appends and advances cursor + anchor. + it("insert places cell at cursor and advances both cursor and anchor", () => { + buffer.insert(glyph("A")); + + expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cursor).toBe(1); + expect(buffer.anchor).toBe(1); + expect(buffer.hasSelection).toBe(false); + }); + + // before: [A, B, C] anchor=1, cursor=3 selection = [1, 3) → B, C + // insert(X) + // after: [A, X] cursor = anchor = 2 (X collapses BC + caret advances) + it("insert replaces an active selection", () => { + buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + buffer.selectRange(1, 3); + + buffer.insert(glyph("X")); + + expect(buffer.cells).toEqual([glyph("A"), glyph("X")]); + expect(buffer.cursor).toBe(2); + expect(buffer.anchor).toBe(2); + }); + + // SEED — backspace with no selection removes one cell before cursor. + it("delete removes cell before cursor when there is no selection", () => { + buffer.insertMany([glyph("A"), glyph("B")]); // cursor=2 + + expect(buffer.delete()).toBe(true); + + expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cursor).toBe(1); + }); + + // SEED — backspace at start with no selection is a no-op returning false. + it("delete at buffer start with no selection returns false", () => { + expect(buffer.delete()).toBe(false); + expect(buffer.cells).toEqual([]); + }); + + // before: [A, B, C] anchor=1, cursor=3 selection = [1, 3) → B, C + // delete() + // after: [A] cursor = anchor = 1 (selection removed, caret at start) + it("delete with active selection removes it and collapses to start", () => { + buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + buffer.selectRange(1, 3); + + expect(buffer.delete()).toBe(true); + + expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cursor).toBe(1); + expect(buffer.anchor).toBe(1); + }); + + // SEED — selectAll spans the whole buffer. + it("selectAll spans 0..length", () => { + buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + + buffer.selectAll(); + + expect(buffer.anchor).toBe(0); + expect(buffer.cursor).toBe(3); + expect(buffer.range).toEqual({ start: 0, end: 3 }); + }); + + // [A, B] has length 2 → valid positions are [0, 1, 2] + // placeCaret(99) clamps to length=2; anchor and cursor both land there. + it("placeCaret clamps and collapses selection", () => { + buffer.insertMany([glyph("A"), glyph("B")]); + buffer.selectRange(0, 2); + + buffer.placeCaret(99); + + expect(buffer.cursor).toBe(2); + expect(buffer.anchor).toBe(2); + expect(buffer.hasSelection).toBe(false); + }); + + // SEED — snapshot / restore round-trip. + it("snapshot then restore reproduces buffer state", () => { + buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + buffer.selectRange(1, 3); + buffer.setOriginX(42); + + const snap = buffer.snapshot(); + + const fresh = new TextBuffer(); + fresh.restore(snap); + + expect(fresh.cells).toEqual([glyph("A"), glyph("B"), glyph("C")]); + expect(fresh.anchor).toBe(1); + expect(fresh.cursor).toBe(3); + expect(fresh.originX).toBe(42); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts new file mode 100644 index 00000000..b323c822 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts @@ -0,0 +1,302 @@ +/** + * TextBuffer — pure logical state of an editable text run. + * + * Owns: cell buffer + cursor + anchor + originX. + * + * Selection model: DOM-style (anchor / focus). + * - anchor: where the selection started (stays during shift+arrow) + * - cursor: where the caret is (moves during shift+arrow) + * - anchor === cursor → no selection, just a caret + * - anchor !== cursor → selection from min(...) to max(...) + * + * Per-field signals so consumers subscribe only to what they need — + * a cursor move does not refire `$cells` subscribers. Multi-field + * mutations go through `#update(patch)` which batches all signal sets, + * keeping the `batch(() => ...)` boilerplate out of every method. + * + * Does NOT own: hover, editing-context, layout, caret geometry, + * cursorVisible (transient UI state). Those live in `TextInteraction` / + * `TextRun`. + */ +import { signal, batch, type WritableSignal, type Signal } from "@/lib/reactive/signal"; +import type { Cell } from "./layout"; +import { clamp } from "@/lib/utils/utils"; + +export interface SelectionRange { + start: number; + end: number; +} + +/** All-fields-required snapshot of a TextBuffer; safe to round-trip through persistence. */ +export interface TextBufferSnapshot { + cells: Cell[]; + cursor: number; + anchor: number; + originX: number; +} + +/** Patch shape for `#update`. Any field omitted is left alone. */ +interface TextBufferPatch { + cells?: readonly Cell[]; + cursor?: number; + anchor?: number; + originX?: number; +} + +export class TextBuffer { + readonly #$cells: WritableSignal; + readonly #$cursor: WritableSignal; + readonly #$anchor: WritableSignal; + readonly #$originX: WritableSignal; + + constructor() { + this.#$cells = signal([]); + this.#$cursor = signal(0); + this.#$anchor = signal(0); + this.#$originX = signal(0); + } + + get cells(): readonly Cell[] { + return this.#$cells.value; + } + + get cursor(): number { + return this.#$cursor.value; + } + + get anchor(): number { + return this.#$anchor.value; + } + + get originX(): number { + return this.#$originX.value; + } + + get length(): number { + return this.#$cells.value.length; + } + + get hasSelection(): boolean { + return this.#$anchor.value !== this.#$cursor.value; + } + + /** + * Always-defined selection span. Collapses to `{start: cursor, end: cursor}` + * when there's no active selection. Mutators use this so they don't have to + * branch on null — `slice(0, start) + slice(end)` is the same splice math + * whether or not a selection exists. + * + * anchor=3, cursor=1 (backward selection): + * [A, B, C, D] range = { start: 1, end: 3 } → B, C + * + * no selection (anchor=cursor=2): + * [A, B, C, D] range = { start: 2, end: 2 } → empty + */ + get range(): SelectionRange { + const a = this.#$anchor.value; + const c = this.#$cursor.value; + return { start: Math.min(a, c), end: Math.max(a, c) }; + } + + /** @knipclassignore — read by HiddenTextInput's copy handler */ + get selectedCells(): Cell[] { + if (!this.hasSelection) return []; + const { start, end } = this.range; + return this.#$cells.peek().slice(start, end); + } + + /** Raw signals for React hooks that need `Signal`. */ + get $cells(): Signal { + return this.#$cells; + } + + get $cursor(): Signal { + return this.#$cursor; + } + + get $anchor(): Signal { + return this.#$anchor; + } + + get $originX(): Signal { + return this.#$originX; + } + + /** + * Insert one cell at the cursor. Replaces the current selection + * (if any) before inserting. Cursor and anchor advance to the + * position immediately after the inserted cell. + * + * no selection (cursor=2): + * before: [A, B, C] cursor = anchor = 2 + * insert(X) + * after: [A, B, X, C] cursor = anchor = 3 + * + * with selection (anchor=1, cursor=3): + * before: [A, B, C, D] selection = [1, 3) → B, C + * insert(X) + * after: [A, X, D] cursor = anchor = 2 + */ + insert(cell: Cell): void { + const { start, end } = this.range; + const next = [...this.cells.slice(0, start), cell, ...this.cells.slice(end)]; + const pos = start + 1; + + this.#update({ cells: next, cursor: pos, anchor: pos }); + } + + /** + * Insert many cells at the cursor. Replaces the current selection. + * Cursor and anchor land at `start + cells.length`. + */ + insertMany(cells: readonly Cell[]): void { + const { start, end } = this.range; + const next = [...this.cells.slice(0, start), ...cells, ...this.cells.slice(end)]; + const pos = start + cells.length; + + this.#update({ cells: next, cursor: pos, anchor: pos }); + } + + /** + * Insert at a specific buffer index, ignoring current selection. + * Existing cursor/anchor shift right by 1 if they sit at or after `index`. + */ + /** @knipclassignore — used by Select tool's TextRunEdit splice (TODO) */ + insertAt(_index: number, _cell: Cell): void { + throw new Error("TextBuffer.insertAt not implemented"); + } + + /** + * Backspace. If a selection is active, deletes the selection; + * cursor lands at selection start. Otherwise deletes one cell + * before the cursor. + * + * Returns true when something was deleted, false when there's + * nothing to delete (cursor at start, no selection). + */ + delete(): boolean { + if (this.hasSelection) { + const { start, end } = this.range; + const next = [...this.cells.slice(0, start), ...this.cells.slice(end)]; + this.#update({ cells: next, cursor: start, anchor: start }); + return true; + } + + if (this.cursor === 0) return false; + + const pos = this.cursor - 1; + const next = [...this.cells.slice(0, pos), ...this.cells.slice(pos + 1)]; + this.#update({ cells: next, cursor: pos, anchor: pos }); + return true; + } + + /** + * Forward delete. If a selection is active, deletes it; cursor + * lands at selection start. Otherwise deletes the cell at cursor. + */ + deleteForward(): boolean { + if (this.hasSelection) { + const { start, end } = this.range; + const next = [...this.cells.slice(0, start), ...this.cells.slice(end)]; + this.#update({ cells: next, cursor: start, anchor: start }); + return true; + } + + if (this.cursor >= this.length) return false; + + const next = [...this.cells.slice(0, this.cursor), ...this.cells.slice(this.cursor + 1)]; + this.#update({ cells: next }); + return true; + } + + /** + * Place caret at `index` (clamped to [0, length]). Anchor === cursor, + * collapsing any selection. + */ + placeCaret(index: number): void { + const pos = clamp(index, 0, this.length); + this.#update({ cursor: pos, anchor: pos }); + } + + /** + * Move cursor to `index`, leaving anchor untouched. Used by + * shift+arrow / shift+click to extend the selection. + */ + extendSelection(index: number): void { + this.#$cursor.set(clamp(index, 0, this.length)); + } + + /** + * Set anchor and cursor explicitly. Both clamped to [0, length]. + */ + selectRange(anchor: number, cursor: number): void { + const len = this.length; + this.#update({ + anchor: clamp(anchor, 0, len), + cursor: clamp(cursor, 0, len), + }); + } + + /** anchor=0, cursor=length. */ + selectAll(): void { + this.#update({ anchor: 0, cursor: this.length }); + } + + /** + * Collapse selection to its `start` or `end`. No-op when there's + * no selection. + */ + /** @knipclassignore — public buffer API */ + collapseSelection(to: "start" | "end"): void { + if (!this.hasSelection) return; + const { start, end } = this.range; + const pos = to === "start" ? start : end; + this.#update({ cursor: pos, anchor: pos }); + } + + setOriginX(x: number): void { + this.#$originX.set(x); + } + + /** + * Initialize with a single cell if and only if the buffer is empty. + * Cursor + anchor land at index 1. Used by Text tool activation to + * plant the active glyph. + */ + seed(cell: Cell): void { + if (this.length > 0) return; + this.#update({ cells: [cell], cursor: 1, anchor: 1 }); + } + + /** Empty the buffer; cursor/anchor/originX reset to 0. */ + clear(): void { + this.#update({ cells: [], cursor: 0, anchor: 0, originX: 0 }); + } + + snapshot(): TextBufferSnapshot { + return { + cells: [...this.cells], + cursor: this.cursor, + anchor: this.anchor, + originX: this.originX, + }; + } + + restore(snapshot: TextBufferSnapshot): void { + this.#update(snapshot); + } + + /** + * Apply a partial state patch atomically. Any field omitted is left alone. + * All signal sets run inside `batch()` so subscribers see one transition, + * not intermediate states. + */ + #update(patch: TextBufferPatch): void { + batch(() => { + if (patch.cells !== undefined) this.#$cells.set(patch.cells); + if (patch.cursor !== undefined) this.#$cursor.set(patch.cursor); + if (patch.anchor !== undefined) this.#$anchor.set(patch.anchor); + if (patch.originX !== undefined) this.#$originX.set(patch.originX); + }); + } +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts new file mode 100644 index 00000000..5c0aa4b5 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TextInteraction } from "./TextInteraction"; +import { glyphCell as glyph } from "./layout"; + +describe("TextInteraction", () => { + let ctx: TextInteraction; + + beforeEach(() => { + ctx = new TextInteraction(); + }); + + it("starts with everything null", () => { + expect(ctx.editing).toBeNull(); + expect(ctx.suspended).toBeNull(); + expect(ctx.hoveredIndex).toBeNull(); + }); + + it("setEditing stores the target", () => { + ctx.setEditing({ index: 3, cell: glyph("A", 65) }); + + expect(ctx.editing).toEqual({ index: 3, cell: glyph("A", 65) }); + }); + + it("suspend moves editing into suspended", () => { + const target = { index: 3, cell: glyph("A", 65) }; + ctx.setEditing(target); + + ctx.suspend(); + + expect(ctx.editing).toBeNull(); + expect(ctx.suspended).toEqual(target); + }); + + it("resume moves suspended back to editing and returns it", () => { + const target = { index: 3, cell: glyph("A", 65) }; + ctx.setEditing(target); + ctx.suspend(); + + const restored = ctx.resume(); + + expect(restored).toEqual(target); + expect(ctx.editing).toEqual(target); + expect(ctx.suspended).toBeNull(); + }); + + it("resume with nothing suspended returns null", () => { + expect(ctx.resume()).toBeNull(); + expect(ctx.editing).toBeNull(); + }); + + it("clear resets everything to null", () => { + ctx.setEditing({ index: 1, cell: glyph("X") }); + ctx.suspend(); + ctx.setEditing({ index: 2, cell: glyph("Y") }); + ctx.setHovered(7); + + ctx.clear(); + + expect(ctx.editing).toBeNull(); + expect(ctx.suspended).toBeNull(); + expect(ctx.hoveredIndex).toBeNull(); + }); + + // before: [_, _, _, _, X, _, _, _] editing.index = 4 (X) + // └─delete─┘ at=3, count=3 → deletes indices 3,4,5 + // after: [_, _, _, _, _] editing → null (X was inside) + it("adjustForBufferChange nulls indices inside the deleted range", () => { + ctx.setEditing({ index: 4, cell: glyph("A") }); + ctx.adjustForBufferChange(3, 3, 0); + + expect(ctx.editing).toBeNull(); + }); + + // before: [_, _, _, _, _, _, _, X] editing.index = 7 (X) + // └─delete─┘ at=2, count=3 → deletes indices 2,3,4 + // after: [_, _, _, _, X] editing.index = 4 (shifted left by 3) + it("adjustForBufferChange shifts indices after a deletion", () => { + ctx.setEditing({ index: 7, cell: glyph("A") }); + ctx.adjustForBufferChange(2, 3, 0); + + expect(ctx.editing?.index).toBe(4); + }); + + // before: [_, _, _, _, _, X] editing.index = 5 (X) + // ↑ insert 2 cells at index 3 + // after: [_, _, _, ?, ?, _, _, X] editing.index = 7 (shifted right by 2) + it("adjustForBufferChange shifts indices at or after an insertion", () => { + ctx.setEditing({ index: 5, cell: glyph("A") }); + ctx.adjustForBufferChange(3, 0, 2); + + expect(ctx.editing?.index).toBe(7); + }); + + it("snapshot then restore reproduces context state", () => { + ctx.setEditing({ index: 4, cell: glyph("A", 65) }); + ctx.suspend(); + ctx.setEditing({ index: 1, cell: glyph("B", 66) }); + ctx.setHovered(3); + + const snap = ctx.snapshot(); + + const fresh = new TextInteraction(); + fresh.restore(snap); + + expect(fresh.editing).toEqual({ index: 1, cell: glyph("B", 66) }); + expect(fresh.suspended).toEqual({ index: 4, cell: glyph("A", 65) }); + expect(fresh.hoveredIndex).toBe(3); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts new file mode 100644 index 00000000..cc25fc1e --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts @@ -0,0 +1,190 @@ +/** + * TextInteraction — per-run UI focus state. Sibling of TextBuffer; both are + * composed by TextRun. + * + * Owns: + * - editing slot — which cell index in the run is open for in-place glyph + * editing (set when the user double-clicks a slot to dive into editing + * the glyph at that position) + * - suspended editing — what was active before the user exited the Text + * tool, so re-activation can restore it + * - hover index — slot under the pointer for visual highlight + * + * Per-field signals so a hover update doesn't refire editing-slot subscribers. + * `#update(patch)` batches multi-field mutations. + * + * Does NOT own: cells, cursor, anchor, originX (TextBuffer), layout/caret + * (TextRun), cursorVisible (TextRun). Composite-glyph drill-through state + * will live in its own `CompositeInspection` class when that feature is + * rebuilt — intentionally not folded in here. + */ +import { signal, batch, type WritableSignal, type Signal } from "@/lib/reactive/signal"; +import type { Cell } from "./layout"; + +export interface EditingTarget { + index: number; + cell: Cell; +} + +interface TextInteractionState { + editing?: EditingTarget | null; + suspended?: EditingTarget | null; + hoveredIndex?: number | null; +} + +export type TextInteractionSnapshot = TextInteractionState; + +export class TextInteraction { + readonly #$editing: WritableSignal; + readonly #$suspended: WritableSignal; + readonly #$hoveredIndex: WritableSignal; + + constructor() { + this.#$editing = signal(null); + this.#$suspended = signal(null); + this.#$hoveredIndex = signal(null); + } + + get editing(): EditingTarget | null { + return this.#$editing.value; + } + + get suspended(): EditingTarget | null { + return this.#$suspended.value; + } + + get hoveredIndex(): number | null { + return this.#$hoveredIndex.value; + } + + /** @knipclassignore — read by TextRunRenderer / TextRunEdit (TODO) */ + get $editing(): Signal { + return this.#$editing; + } + + /** @knipclassignore — read by TextRunRenderer / TextRunHover (TODO) */ + get $hoveredIndex(): Signal { + return this.#$hoveredIndex; + } + + /** + * Open in-place editing for the cell at `index`. Replaces whatever was + * being edited before. + */ + setEditing(target: EditingTarget | null): void { + this.#$editing.set(target); + } + + /** + * Move current editing → suspended; clear editing. Used when the user + * exits the Text tool — the editing slot is remembered so re-entry can + * restore it. + */ + suspend(): void { + if (this.#$suspended.value) return; + + const suspended = this.#$editing.value; + this.#update({ + suspended, + editing: null, + }); + } + + /** + * Move suspended → editing; clear suspended. Returns the restored target, + * or null if nothing was suspended. + */ + resume(): EditingTarget | null { + const editing = this.#$suspended.value; + + this.#update({ + suspended: null, + editing: editing, + }); + + return editing; + } + + setHovered(index: number | null): void { + this.#$hoveredIndex.set(index); + } + + /** + * Adjust held indices (editing, suspended) after a buffer mutation at `at` + * that removed `deleteCount` cells and inserted `insertCount` in their + * place. Indices that fell within the removed range become null; indices + * after the mutation shift by `insertCount - deleteCount`. + * + * Covers all three operations: + * - delete-only: adjustForBufferChange(start, count, 0) + * - insert-only: adjustForBufferChange(at, 0, count) + * - replace: adjustForBufferChange(start, deleteCount, insertCount) + * + * Examples (editing target shown above the buffer): + * + * editing.index = 4 → delete cells 1..4 (at=1, count=3, insert=0): + * before: [A, B, C, D, E, F] index 4 = E + * after: [A, E, F] index 1 (shifted left by 3) + * + * editing.index = 3 → delete cell 3 (at=3, count=1, insert=0): + * before: [A, B, C, D, E] index 3 = D (the held cell) + * after: [A, B, C, E] editing → null (D was deleted) + * + * editing.index = 3 → insert 2 at index 1 (at=1, count=0, insert=2): + * before: [A, B, C, D, E] index 3 = D + * after: [A, X, Y, B, C, D, E] index 5 (shifted right by 2) + * + * Called by TextRun after every TextBuffer mutation that changes cell + * positions, to keep the editing context coherent with the buffer. + */ + adjustForBufferChange(at: number, deleteCount: number, insertCount: number): void { + const adjust = (t: EditingTarget | null): EditingTarget | null => { + if (!t) return null; + let i = t.index; + + if (deleteCount > 0) { + if (i >= at && i < at + deleteCount) return null; + if (i >= at + deleteCount) i -= deleteCount; + } + if (insertCount > 0 && i >= at) { + i += insertCount; + } + + return i === t.index ? t : { ...t, index: i }; + }; + + this.#update({ + editing: adjust(this.editing), + suspended: adjust(this.suspended), + }); + } + + /** Reset everything to null. */ + clear(): void { + this.#update({ + editing: null, + suspended: null, + hoveredIndex: null, + }); + } + + snapshot(): TextInteractionSnapshot { + return { + editing: this.editing, + suspended: this.suspended, + hoveredIndex: this.hoveredIndex, + }; + } + + restore(snapshot: TextInteractionSnapshot): void { + this.#update(snapshot); + } + + #update(patch: TextInteractionState): void { + batch(() => { + if (patch.editing !== undefined) this.#$editing.set(patch.editing); + if (patch.suspended !== undefined) this.#$suspended.set(patch.suspended); + if (patch.hoveredIndex !== undefined) this.#$hoveredIndex.set(patch.hoveredIndex); + }); + } +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts new file mode 100644 index 00000000..b9271333 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts @@ -0,0 +1,249 @@ +/** + * TextRun — composes TextBuffer + TextInteraction, exposes reactive layout + * and caret signals, owns transient nav state (#goalX for up/down arrow + * goal-x persistence). + * + * Reactive boundaries: + * - $layout: computed from buffer.cells + buffer.originX + font (rebuilt + * when any of those change). Cursor/anchor changes do NOT recompute. + * - $caret: computed from $layout + buffer.cursor. Rebuilt on cursor move + * OR layout rebuild. + * - $selectionRects: computed from $layout + buffer.range + hasSelection. + * + * Cursor math lives on Caret. TextRun threads goalX through Caret.nextLine / + * previousLine so vertical motion preserves horizontal position across short + * lines. goalX resets on horizontal nav, click, and edits. + */ +import { signal, computed, type Signal, type ComputedSignal } from "@/lib/reactive/signal"; +import { TextBuffer } from "./TextBuffer"; +import { TextInteraction } from "./TextInteraction"; +import { Caret, TextLayout } from "./layout"; +import type { Cell, Positioner } from "./layout"; +import type { Font } from "@/lib/model/Font"; + +export interface SelectionRect { + x: number; + width: number; + top: number; + bottom: number; +} + +export class TextRun { + readonly buffer: TextBuffer; + readonly interaction: TextInteraction; + readonly #font: Font; + readonly #positioner: Positioner; + + readonly #$cursorVisible: Signal; + readonly #$layout: ComputedSignal; + readonly #$caret: ComputedSignal; + readonly #$selectionRects: ComputedSignal; + + #goalX: number | null = null; + + constructor(font: Font, positioner: Positioner) { + this.buffer = new TextBuffer(); + this.interaction = new TextInteraction(); + this.#font = font; + this.#positioner = positioner; + this.#$cursorVisible = signal(false); + + this.#$layout = computed(() => { + const cells = this.buffer.cells; + if (cells.length === 0) return null; + return new TextLayout({ + cells, + origin: { x: this.buffer.originX, y: 0 }, + font: this.#font, + positioner: this.#positioner, + }); + }); + + this.#$caret = computed(() => { + const layout = this.#$layout.value; + if (!layout) return null; + return Caret.atCluster(layout, this.buffer.cursor); + }); + + this.#$selectionRects = computed(() => { + const layout = this.#$layout.value; + if (!layout || !this.buffer.hasSelection) return []; + return computeSelectionRects(layout, this.buffer.range); + }); + } + + /** @knipclassignore — read by TextRunRenderer when it lands */ + get cursorVisible(): boolean { + return this.#$cursorVisible.value; + } + + /** @knipclassignore — read by TextRunRenderer / Caret-via-effect callers */ + get $layout(): Signal { + return this.#$layout; + } + + /** @knipclassignore — read by TextRunRenderer / Caret callers */ + get $caret(): Signal { + return this.#$caret; + } + + /** @knipclassignore — read by TextRunRenderer */ + get $selectionRects(): Signal { + return this.#$selectionRects; + } + + setCursorVisible(visible: boolean): void { + (this.#$cursorVisible as ReturnType>).set(visible); + } + + /** + * Initialize the run with a single seed cell at originX. Combines + * `buffer.seed` + `buffer.setOriginX` so Text-tool activation is one call. + * No-op on the seed if the buffer already has cells; originX is always set. + */ + seed(cell: Cell, originX: number): void { + this.buffer.seed(cell); + this.buffer.setOriginX(originX); + } + + /** + * Insert one cell at the cursor. Replaces selection if any; adjusts the + * editing context's held indices for the buffer mutation; resets goalX. + */ + insert(cell: Cell): void { + const before = this.buffer.range; + const deleteCount = before.end - before.start; + this.buffer.insert(cell); + this.interaction.adjustForBufferChange(before.start, deleteCount, 1); + this.#resetGoalX(); + } + + /** @knipclassignore — called via editor.textRun.insertMany (paste, IME commit) */ + insertMany(cells: readonly Cell[]): void { + const before = this.buffer.range; + const deleteCount = before.end - before.start; + this.buffer.insertMany(cells); + this.interaction.adjustForBufferChange(before.start, deleteCount, cells.length); + this.#resetGoalX(); + } + + /** @knipclassignore — called via editor.textRun.delete (Backspace) */ + delete(): boolean { + const before = this.buffer.range; + const wasSelection = this.buffer.hasSelection; + const deleted = this.buffer.delete(); + if (!deleted) return false; + if (wasSelection) { + this.interaction.adjustForBufferChange(before.start, before.end - before.start, 0); + } else { + this.interaction.adjustForBufferChange(before.start - 1, 1, 0); + } + this.#resetGoalX(); + return true; + } + + /** @knipclassignore — called via editor.textRun.deleteForward (Delete) */ + deleteForward(): boolean { + const before = this.buffer.range; + const wasSelection = this.buffer.hasSelection; + const deleted = this.buffer.deleteForward(); + if (!deleted) return false; + if (wasSelection) { + this.interaction.adjustForBufferChange(before.start, before.end - before.start, 0); + } else { + this.interaction.adjustForBufferChange(before.start, 1, 0); + } + this.#resetGoalX(); + return true; + } + + /** @knipclassignore — called via Select tool's text-run hit-test (TODO) */ + placeCaretAtPoint(p: { x: number; y: number }): void { + const layout = this.#$layout.peek(); + if (!layout) return; + const hit = layout.hitTest(p); + if (!hit) return; + const cluster = hit.side === "left" ? hit.cluster : hit.cluster + 1; + this.buffer.placeCaret(cluster); + this.#resetGoalX(); + } + + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorLeft(extend = false): void { + this.#resetGoalX(); + const next = this.buffer.cursor - 1; + if (extend) this.buffer.extendSelection(next); + else this.buffer.placeCaret(next); + } + + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorRight(extend = false): void { + this.#resetGoalX(); + const next = this.buffer.cursor + 1; + if (extend) this.buffer.extendSelection(next); + else this.buffer.placeCaret(next); + } + + // Vertical nav: deferred. No-op for now; lands when Caret.nextLine ships. + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorUp(_extend = false): void { + void this.#goalX; // TODO: read as goalX seed for Caret.previousLine + } + + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorDown(_extend = false): void { + /* TODO: Caret.nextLine(this.#goalX ??= caret.position().x) */ + } + + // Word and line-edge nav: deferred. No-op for now. + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorByWord(_direction: -1 | 1, _extend = false): void { + /* TODO: walk cells looking for word boundaries via codepoint classes */ + } + + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorToLineStart(_extend = false): void { + /* TODO: find current line, set cursor to its clusterStart */ + } + + /** @knipclassignore — keyboard nav via HiddenTextInput */ + moveCursorToLineEnd(_extend = false): void { + /* TODO: find current line, set cursor to its last caret position */ + } + + #resetGoalX(): void { + this.#goalX = null; + } +} + +function computeSelectionRects( + layout: TextLayout, + range: { start: number; end: number }, +): SelectionRect[] { + const rects: SelectionRect[] = []; + for (const line of layout.lines) { + let cursor = layout.origin.x; + let rectStart: number | null = null; + let rectEnd = cursor; + for (const run of line.runs) { + for (const g of run.glyphs) { + const left = cursor; + const right = cursor + g.xAdvance; + if (g.cluster >= range.start && g.cluster < range.end) { + if (rectStart === null) rectStart = left; + rectEnd = right; + } + cursor = right; + } + } + if (rectStart !== null) { + rects.push({ + x: rectStart, + width: rectEnd - rectStart, + top: line.y + line.ascent, + bottom: line.y + line.descent, + }); + } + } + return rects; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.test.ts deleted file mode 100644 index 0f9c198d..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { TextRunController } from "./TextRunController"; - -function glyph(name: string, unicode: number | null = null) { - return { glyphName: name, unicode }; -} - -describe("TextRunController", () => { - let ctrl: TextRunController; - - beforeEach(() => { - ctrl = new TextRunController(); - }); - - describe("insert and cursor", () => { - it("inserts glyphs and advances cursor", () => { - ctrl.insert(glyph("A", 65)); - ctrl.insert(glyph("B", 66)); - - expect(ctrl.length).toBe(2); - expect(ctrl.cursor).toBe(2); - expect(ctrl.glyphs.map((g) => g.glyphName)).toEqual(["A", "B"]); - }); - - it("inserts at cursor position, not at end", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("C")); - ctrl.moveCursorLeft(); - ctrl.insert(glyph("B")); - - expect(ctrl.glyphs.map((g) => g.glyphName)).toEqual(["A", "B", "C"]); - expect(ctrl.cursor).toBe(2); - }); - }); - - describe("delete (backspace)", () => { - it("deletes glyph before cursor", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.delete(); - - expect(ctrl.length).toBe(1); - expect(ctrl.glyphs[0].glyphName).toBe("A"); - expect(ctrl.cursor).toBe(1); - }); - - it("returns false at start of buffer", () => { - expect(ctrl.delete()).toBe(false); - }); - - it("deletes entire selection", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.selectAll(); - ctrl.delete(); - - expect(ctrl.length).toBe(0); - expect(ctrl.cursor).toBe(0); - }); - }); - - describe("deleteForward", () => { - it("deletes glyph after cursor", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.moveCursorToStart(); - ctrl.deleteForward(); - - expect(ctrl.length).toBe(1); - expect(ctrl.glyphs[0].glyphName).toBe("B"); - expect(ctrl.cursor).toBe(0); - }); - - it("returns false at end of buffer", () => { - ctrl.insert(glyph("A")); - expect(ctrl.deleteForward()).toBe(false); - }); - }); - - describe("cursor movement", () => { - beforeEach(() => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - }); - - it("moveCursorLeft moves left by one", () => { - ctrl.moveCursorLeft(); - expect(ctrl.cursor).toBe(2); - }); - - it("moveCursorRight does nothing at end", () => { - ctrl.moveCursorRight(); - expect(ctrl.cursor).toBe(3); - }); - - it("moveCursorToStart jumps to 0", () => { - ctrl.moveCursorToStart(); - expect(ctrl.cursor).toBe(0); - }); - - it("moveCursorToEnd jumps to length", () => { - ctrl.moveCursorToStart(); - ctrl.moveCursorToEnd(); - expect(ctrl.cursor).toBe(3); - }); - - it("clamps at boundaries", () => { - ctrl.moveCursorToStart(); - ctrl.moveCursorLeft(); - expect(ctrl.cursor).toBe(0); - }); - }); - - describe("selection via shift+arrow", () => { - beforeEach(() => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - }); - - it("shift+left creates selection", () => { - ctrl.moveCursorLeft(true); - - expect(ctrl.hasSelection).toBe(true); - expect(ctrl.selection).toEqual({ start: 2, end: 3 }); - expect(ctrl.anchor).toBe(3); - expect(ctrl.cursor).toBe(2); - }); - - it("multiple shift+left grows selection", () => { - ctrl.moveCursorLeft(true); - ctrl.moveCursorLeft(true); - - expect(ctrl.selection).toEqual({ start: 1, end: 3 }); - }); - - it("shift+right from middle extends forward", () => { - ctrl.moveCursorToStart(); - ctrl.moveCursorRight(true); - ctrl.moveCursorRight(true); - - expect(ctrl.selection).toEqual({ start: 0, end: 2 }); - }); - - it("arrow without shift collapses selection", () => { - ctrl.moveCursorLeft(true); - ctrl.moveCursorLeft(true); - ctrl.moveCursorLeft(); // collapse to start - - expect(ctrl.hasSelection).toBe(false); - expect(ctrl.cursor).toBe(1); - }); - - it("right arrow collapses to end of selection", () => { - ctrl.moveCursorLeft(true); - ctrl.moveCursorLeft(true); - ctrl.moveCursorRight(); // collapse to end - - expect(ctrl.hasSelection).toBe(false); - expect(ctrl.cursor).toBe(3); - }); - }); - - describe("selectAll", () => { - it("selects entire buffer", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.selectAll(); - - expect(ctrl.selection).toEqual({ start: 0, end: 2 }); - expect(ctrl.selectedGlyphs.map((g) => g.glyphName)).toEqual(["A", "B"]); - }); - }); - - describe("selectRange", () => { - it("selects a sub-range", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.selectRange(1, 2); - - expect(ctrl.selection).toEqual({ start: 1, end: 2 }); - expect(ctrl.selectedGlyphs.map((g) => g.glyphName)).toEqual(["B"]); - }); - - it("clamps out-of-bounds values", () => { - ctrl.insert(glyph("A")); - ctrl.selectRange(-5, 100); - - expect(ctrl.selection).toEqual({ start: 0, end: 1 }); - }); - }); - - describe("click placement", () => { - beforeEach(() => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - }); - - it("placeCaret collapses selection and moves cursor", () => { - ctrl.selectAll(); - ctrl.placeCaret(1); - - expect(ctrl.hasSelection).toBe(false); - expect(ctrl.cursor).toBe(1); - }); - - it("extendSelection keeps anchor, moves focus", () => { - ctrl.placeCaret(0); - ctrl.extendSelection(2); - - expect(ctrl.selection).toEqual({ start: 0, end: 2 }); - expect(ctrl.anchor).toBe(0); - expect(ctrl.cursor).toBe(2); - }); - }); - - describe("insert replaces selection", () => { - it("replaces selected text on insert", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.selectAll(); - ctrl.insert(glyph("X")); - - expect(ctrl.length).toBe(1); - expect(ctrl.glyphs[0].glyphName).toBe("X"); - expect(ctrl.cursor).toBe(1); - expect(ctrl.hasSelection).toBe(false); - }); - - it("replaces partial selection", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.selectRange(1, 2); - ctrl.insert(glyph("X")); - - expect(ctrl.glyphs.map((g) => g.glyphName)).toEqual(["A", "X", "C"]); - }); - }); - - describe("insertMany (paste)", () => { - it("inserts multiple glyphs and replaces selection", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("D")); - ctrl.selectRange(1, 1); // no selection, cursor at 1 - ctrl.placeCaret(1); - ctrl.insertMany([glyph("B"), glyph("C")]); - - expect(ctrl.glyphs.map((g) => g.glyphName)).toEqual(["A", "B", "C", "D"]); - expect(ctrl.cursor).toBe(3); - }); - }); - - describe("seed", () => { - it("seeds empty buffer with initial glyph", () => { - ctrl.seed(glyph("A")); - - expect(ctrl.length).toBe(1); - expect(ctrl.cursor).toBe(1); - }); - - it("does nothing if buffer is non-empty", () => { - ctrl.insert(glyph("X")); - ctrl.seed(glyph("A")); - - expect(ctrl.length).toBe(1); - expect(ctrl.glyphs[0].glyphName).toBe("X"); - }); - }); - - describe("snapshot / restore", () => { - it("roundtrips state", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.moveCursorLeft(true); // select B - ctrl.setOriginX(500); - - const snap = ctrl.snapshot(); - const ctrl2 = new TextRunController(); - ctrl2.restore(snap); - - expect(ctrl2.length).toBe(2); - expect(ctrl2.cursor).toBe(1); - expect(ctrl2.anchor).toBe(2); - expect(ctrl2.selection).toEqual({ start: 1, end: 2 }); - }); - }); - - describe("collapseSelection", () => { - it("collapses to start", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.selectAll(); - ctrl.collapseSelection("start"); - - expect(ctrl.cursor).toBe(0); - expect(ctrl.hasSelection).toBe(false); - }); - - it("collapses to end", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.selectAll(); - ctrl.collapseSelection("end"); - - expect(ctrl.cursor).toBe(2); - expect(ctrl.hasSelection).toBe(false); - }); - }); - - describe("clear", () => { - it("resets all state", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.selectAll(); - ctrl.setOriginX(100); - ctrl.clear(); - - expect(ctrl.length).toBe(0); - expect(ctrl.cursor).toBe(0); - expect(ctrl.hasSelection).toBe(false); - expect(ctrl.state.peek()).toBe(null); - }); - }); - - describe("suspendEditing / resumeEditing", () => { - it("suspends and resumes editing index", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.setEditingSlot(1, glyph("B")); - - ctrl.suspendEditing(); - - const restored = ctrl.resumeEditing(); - expect(restored).toEqual({ index: 1, glyph: glyph("B") }); - }); - - it("tracks editing index through insert before", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.setEditingSlot(1, glyph("B")); - - ctrl.suspendEditing(); - ctrl.placeCaret(0); - ctrl.insert(glyph("X")); - - const restored = ctrl.resumeEditing(); - expect(restored?.index).toBe(2); - expect(restored?.glyph).toEqual(glyph("B")); - }); - - it("tracks editing index through insert after", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.setEditingSlot(0, glyph("A")); - - ctrl.suspendEditing(); - ctrl.insert(glyph("C")); - - const restored = ctrl.resumeEditing(); - expect(restored?.index).toBe(0); - }); - - it("nulls editing index when editing glyph is deleted", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.setEditingSlot(1, glyph("B")); - - ctrl.suspendEditing(); - ctrl.placeCaret(2); - ctrl.delete(); - - const restored = ctrl.resumeEditing(); - expect(restored).toBeNull(); - }); - - it("tracks editing index through deleteForward", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.setEditingSlot(2, glyph("C")); - - ctrl.suspendEditing(); - ctrl.placeCaret(0); - ctrl.deleteForward(); - - const restored = ctrl.resumeEditing(); - expect(restored?.index).toBe(1); - }); - - it("tracks editing index through selection delete", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.insert(glyph("C")); - ctrl.insert(glyph("D")); - ctrl.setEditingSlot(3, glyph("D")); - - ctrl.suspendEditing(); - ctrl.selectRange(0, 2); - ctrl.delete(); - - const restored = ctrl.resumeEditing(); - expect(restored?.index).toBe(1); - }); - - it("tracks editing index through insertMany", () => { - ctrl.insert(glyph("A")); - ctrl.insert(glyph("B")); - ctrl.setEditingSlot(1, glyph("B")); - - ctrl.suspendEditing(); - ctrl.placeCaret(0); - ctrl.insertMany([glyph("X"), glyph("Y")]); - - const restored = ctrl.resumeEditing(); - expect(restored?.index).toBe(3); - }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts deleted file mode 100644 index 23a7e4b9..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts +++ /dev/null @@ -1,938 +0,0 @@ -/** - * TextRunController — reactive text buffer with cursor, selection, and layout. - * - * All state is immutable signals. Mutations replace the state value. - * The render state is a computed that derives layout, selection rects, - * and cursor position from the buffer state — zero manual recompute calls. - * - * Selection uses the anchor/focus model (same as DOM Selection API): - * - anchor: where the selection started (stays put during shift+arrow) - * - focus: where the caret is (moves during shift+arrow) - * - when anchor === focus, there is no selection (just a caret) - */ -import { - signal, - computed, - type Signal, - type WritableSignal, - type ComputedSignal, -} from "@/lib/reactive/signal"; -import { computeTextLayout, type GlyphRef, type GlyphSlot, type TextLayout } from "./layout"; -import type { Font } from "@/lib/model/Font"; -import type { FontMetrics } from "@shift/types"; - -export interface SelectionRange { - start: number; - end: number; -} - -export interface SelectionRect { - x: number; - width: number; - top: number; - bottom: number; -} - -export interface TextRunCompositeInspection { - slotIndex: number; - hoveredComponentIndex: number | null; -} - -export interface TextRunRenderState { - layout: TextLayout; - cursorX: number | null; - cursorY: number; - selection: SelectionRange | null; - selectionRects: SelectionRect[]; - editingIndex: number | null; - editingGlyph: GlyphRef | null; - hoveredIndex: number | null; - compositeInspection: TextRunCompositeInspection | null; -} - -export interface TextRunSnapshot { - glyphs: GlyphRef[]; - cursor: number; - anchor: number; - originX: number; - editingIndex: number | null; - editingGlyph: GlyphRef | null; -} - -export interface PersistedTextRun { - glyphs: GlyphRef[]; - cursorPosition: number; - originX: number; - editingIndex: number | null; - editingGlyph: GlyphRef | null; -} - -const DEFAULT_TEXT_RUN_KEY = "__default__"; - -interface RunState { - readonly glyphs: readonly GlyphRef[]; - readonly cursor: number; - readonly anchor: number; - readonly originX: number; - readonly cursorVisible: boolean; - readonly editingIndex: number | null; - readonly editingGlyph: GlyphRef | null; - readonly suspendedEditingIndex: number | null; - readonly suspendedEditingGlyph: GlyphRef | null; - readonly hoveredIndex: number | null; - readonly inspectionSlotIndex: number | null; - readonly inspectionHoveredComponentIndex: number | null; -} - -const EMPTY_RUN: RunState = { - glyphs: [], - cursor: 0, - anchor: 0, - originX: 0, - cursorVisible: false, - editingIndex: null, - editingGlyph: null, - suspendedEditingIndex: null, - suspendedEditingGlyph: null, - hoveredIndex: null, - inspectionSlotIndex: null, - inspectionHoveredComponentIndex: null, -}; - -/** - * Adjusts an index after a delete-then-insert operation on the glyph array. - * Returns null if the index falls within the deleted range. - */ -function adjustIndex( - index: number | null, - deleteStart: number, - deleteCount: number, - insertAt: number, - insertCount: number, -): number | null { - if (index === null) return null; - - if (deleteCount > 0) { - if (index >= deleteStart && index < deleteStart + deleteCount) return null; - if (index >= deleteStart + deleteCount) index -= deleteCount; - } - - if (insertCount > 0 && index >= insertAt) { - index += insertCount; - } - - return index; -} - -export class TextRunController { - #runs = new Map>(); - #$activeKey: WritableSignal; - #$font: WritableSignal; - #goalX: number | null = null; - - #$state: ComputedSignal; - - constructor() { - this.#$activeKey = signal(DEFAULT_TEXT_RUN_KEY); - this.#$font = signal(null); - this.#$state = computed(() => this.#deriveRenderState()); - } - - get state(): Signal { - return this.#$state; - } - - get length(): number { - return this.#peek().glyphs.length; - } - - get cursor(): number { - return this.#peek().cursor; - } - - get anchor(): number { - return this.#peek().anchor; - } - - get hasSelection(): boolean { - const r = this.#peek(); - return r.anchor !== r.cursor; - } - - get selection(): SelectionRange | null { - const r = this.#peek(); - if (r.anchor === r.cursor) return null; - return { - start: Math.min(r.anchor, r.cursor), - end: Math.max(r.anchor, r.cursor), - }; - } - - get glyphs(): readonly GlyphRef[] { - return this.#peek().glyphs; - } - - get selectedGlyphs(): GlyphRef[] { - const sel = this.selection; - if (!sel) return []; - return this.#peek().glyphs.slice(sel.start, sel.end); - } - - moveCursorLeft(extend = false): void { - this.#goalX = null; - this.#update((r) => { - if (!extend && r.anchor !== r.cursor) { - const start = Math.min(r.anchor, r.cursor); - return { ...r, cursor: start, anchor: start }; - } - - const cursor = Math.max(0, r.cursor - 1); - return { ...r, cursor, anchor: extend ? r.anchor : cursor }; - }); - } - - moveCursorRight(extend = false): void { - this.#goalX = null; - this.#update((r) => { - if (!extend && r.anchor !== r.cursor) { - const end = Math.max(r.anchor, r.cursor); - return { ...r, cursor: end, anchor: end }; - } - - const cursor = Math.min(r.glyphs.length, r.cursor + 1); - return { ...r, cursor, anchor: extend ? r.anchor : cursor }; - }); - } - - /** @knipclassignore — used via editor.textRunController in HiddenTextInput */ - moveCursorToLineStart(extend = false): void { - this.#goalX = null; - const cursorY = this.#getCursorLineY(); - if (cursorY === null) return; - - const state = this.#$state.peek()!; - const slots = state.layout.slots; - - let lineStart = 0; - for (let i = 0; i < slots.length; i++) { - if (slots[i].y === cursorY) { - lineStart = i; - break; - } - } - - this.#update((r) => ({ ...r, cursor: lineStart, anchor: extend ? r.anchor : lineStart })); - } - - /** @knipclassignore — used via editor.textRunController in HiddenTextInput */ - moveCursorToLineEnd(extend = false): void { - this.#goalX = null; - const cursorY = this.#getCursorLineY(); - if (cursorY === null) return; - - const state = this.#$state.peek()!; - const slots = state.layout.slots; - - let lineEnd = slots.length; - for (let i = slots.length - 1; i >= 0; i--) { - if (slots[i].y === cursorY) { - lineEnd = i + 1; - if (slots[i].unicode === 10) lineEnd = i; - break; - } - } - - this.#update((r) => ({ ...r, cursor: lineEnd, anchor: extend ? r.anchor : lineEnd })); - } - - moveCursorToStart(extend = false): void { - this.#goalX = null; - this.#update((r) => ({ ...r, cursor: 0, anchor: extend ? r.anchor : 0 })); - } - - moveCursorToEnd(extend = false): void { - this.#goalX = null; - this.#update((r) => ({ - ...r, - cursor: r.glyphs.length, - anchor: extend ? r.anchor : r.glyphs.length, - })); - } - - /** @knipclassignore — used via editor.textRunController in HiddenTextInput */ - moveCursorVertically(direction: 1 | -1, extend = false): void { - const state = this.#$state.peek(); - if (!state) return; - - const font = this.#$font.peek(); - if (!font) return; - - const { layout } = state; - const slots = layout.slots; - if (slots.length === 0) return; - - const r = this.#peek(); - const metrics = font.getMetrics(); - const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); - const prevSlot = r.cursor > 0 ? slots[r.cursor - 1] : null; - - // Determine the cursor's effective Y position. - // If the cursor is right after a newline, it's on the NEXT line. - let currentY: number; - let cursorX: number; - - if (prevSlot && prevSlot.unicode === 10) { - currentY = prevSlot.y - lineHeight; - cursorX = r.originX; - } else if (prevSlot) { - currentY = prevSlot.y; - cursorX = prevSlot.x + prevSlot.advance; - } else { - currentY = slots[0]?.y ?? 0; - cursorX = r.originX; - } - - if (this.#goalX === null) this.#goalX = cursorX; - const goalX = this.#goalX; - - // Find unique line Y values, sorted descending (higher Y = higher on screen in UPM) - const lineYs = [...new Set(slots.map((s) => s.y))].sort((a, b) => b - a); - - // Also include the line below the last newline (cursor can be there) - for (const slot of slots) { - if (slot.unicode === 10) { - const belowY = slot.y - lineHeight; - if (!lineYs.includes(belowY)) lineYs.push(belowY); - } - } - lineYs.sort((a, b) => b - a); - - // Find closest line to currentY - let currentLineIdx = 0; - let closestDist = Infinity; - for (let i = 0; i < lineYs.length; i++) { - const dist = Math.abs(lineYs[i] - currentY); - if (dist < closestDist) { - closestDist = dist; - currentLineIdx = i; - } - } - - const targetLineIdx = currentLineIdx + direction; - if (targetLineIdx < 0 || targetLineIdx >= lineYs.length) return; - - const targetY = lineYs[targetLineIdx]; - - // Find the slot on the target line where goalX falls, placing cursor - // on whichever edge (left or right) is closer — same as VS Code. - let bestIdx = -1; - let bestDist = Infinity; - for (const [i, slot] of slots.entries()) { - if (slot.y !== targetY || slot.unicode === 10) continue; - - const leftEdge = slot.x; - const rightEdge = slot.x + slot.advance; - - // Distance to left edge (cursor before this slot) - const leftDist = Math.abs(leftEdge - goalX); - if (leftDist < bestDist) { - bestDist = leftDist; - bestIdx = i; // cursor before this slot - } - - // Distance to right edge (cursor after this slot) - const rightDist = Math.abs(rightEdge - goalX); - if (rightDist < bestDist) { - bestDist = rightDist; - bestIdx = i + 1; // cursor after this slot - } - } - - // If no visible slots on target line (empty line after newline), - // place cursor at the newline that created this line - if (bestIdx === -1) { - for (const [i, slot] of slots.entries()) { - if (slot.unicode === 10 && Math.abs(slot.y - lineHeight - targetY) < 1) { - bestIdx = i + 1; - break; - } - } - } - - if (bestIdx === -1) return; - - if (extend) { - this.extendSelection(bestIdx); - } else { - this.placeCaret(bestIdx); - } - this.#goalX = goalX; - } - - /** @knipclassignore — used via editor.textRunController in HiddenTextInput */ - moveCursorByWord(direction: -1 | 1, extend = false): void { - this.#goalX = null; - const r = this.#peek(); - const glyphs = r.glyphs; - let pos = r.cursor; - - if (direction === -1) { - if (pos <= 0) return; - pos--; - // Skip whitespace - while (pos > 0 && isWhitespace(glyphs[pos - 1])) pos--; - // Skip word characters - while (pos > 0 && !isWhitespace(glyphs[pos - 1]) && !isPunctuation(glyphs[pos - 1])) pos--; - } else { - if (pos >= glyphs.length) return; - // Skip word characters - while (pos < glyphs.length && !isWhitespace(glyphs[pos]) && !isPunctuation(glyphs[pos])) - pos++; - // Skip whitespace - while (pos < glyphs.length && isWhitespace(glyphs[pos])) pos++; - } - - this.#update((r) => ({ ...r, cursor: pos, anchor: extend ? r.anchor : pos })); - } - - selectAll(): void { - this.#goalX = null; - this.#update((r) => ({ ...r, anchor: 0, cursor: r.glyphs.length })); - } - - selectRange(start: number, end: number): void { - this.#update((r) => ({ - ...r, - anchor: Math.max(0, Math.min(start, r.glyphs.length)), - cursor: Math.max(0, Math.min(end, r.glyphs.length)), - })); - } - - collapseSelection(to: "start" | "end" = "end"): void { - this.#update((r) => { - if (r.anchor === r.cursor) return r; - const pos = to === "start" ? Math.min(r.anchor, r.cursor) : Math.max(r.anchor, r.cursor); - return { ...r, cursor: pos, anchor: pos }; - }); - } - - placeCaret(index: number): void { - this.#goalX = null; - this.#update((r) => { - const clamped = Math.max(0, Math.min(index, r.glyphs.length)); - return { ...r, cursor: clamped, anchor: clamped }; - }); - } - - extendSelection(index: number): void { - this.#update((r) => ({ - ...r, - cursor: Math.max(0, Math.min(index, r.glyphs.length)), - })); - } - - insert(glyph: GlyphRef): void { - this.#update((r) => { - const selStart = Math.min(r.anchor, r.cursor); - const selCount = Math.abs(r.anchor - r.cursor); - const { glyphs, cursor } = deleteRange(r); - const next = [...glyphs]; - next.splice(cursor, 0, glyph); - const sei = adjustIndex(r.suspendedEditingIndex, selStart, selCount, cursor, 1); - return { - ...r, - glyphs: next, - cursor: cursor + 1, - anchor: cursor + 1, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - } - - insertAt(index: number, glyph: GlyphRef): void { - this.#update((r) => { - const clamped = Math.max(0, Math.min(index, r.glyphs.length)); - const next = [...r.glyphs]; - next.splice(clamped, 0, glyph); - const sei = adjustIndex(r.suspendedEditingIndex, 0, 0, clamped, 1); - return { - ...r, - glyphs: next, - cursor: r.cursor >= clamped ? r.cursor + 1 : r.cursor, - anchor: r.anchor >= clamped ? r.anchor + 1 : r.anchor, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - } - - insertMany(glyphs: GlyphRef[]): void { - this.#update((r) => { - const selStart = Math.min(r.anchor, r.cursor); - const selCount = Math.abs(r.anchor - r.cursor); - const { glyphs: current, cursor } = deleteRange(r); - const next = [...current]; - next.splice(cursor, 0, ...glyphs); - const sei = adjustIndex(r.suspendedEditingIndex, selStart, selCount, cursor, glyphs.length); - return { - ...r, - glyphs: next, - cursor: cursor + glyphs.length, - anchor: cursor + glyphs.length, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - } - - delete(): boolean { - const r = this.#peek(); - - if (r.anchor !== r.cursor) { - this.#update((r) => { - const selStart = Math.min(r.anchor, r.cursor); - const selCount = Math.abs(r.anchor - r.cursor); - const { glyphs, cursor } = deleteRange(r); - const sei = adjustIndex(r.suspendedEditingIndex, selStart, selCount, 0, 0); - return { - ...r, - glyphs, - cursor, - anchor: cursor, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - return true; - } - - if (r.cursor === 0) return false; - - this.#update((r) => { - const next = [...r.glyphs]; - next.splice(r.cursor - 1, 1); - const sei = adjustIndex(r.suspendedEditingIndex, r.cursor - 1, 1, 0, 0); - return { - ...r, - glyphs: next, - cursor: r.cursor - 1, - anchor: r.cursor - 1, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - return true; - } - - deleteForward(): boolean { - const r = this.#peek(); - - if (r.anchor !== r.cursor) { - this.#update((r) => { - const selStart = Math.min(r.anchor, r.cursor); - const selCount = Math.abs(r.anchor - r.cursor); - const { glyphs, cursor } = deleteRange(r); - const sei = adjustIndex(r.suspendedEditingIndex, selStart, selCount, 0, 0); - return { - ...r, - glyphs, - cursor, - anchor: cursor, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - return true; - } - - if (r.cursor >= r.glyphs.length) return false; - - this.#update((r) => { - const next = [...r.glyphs]; - next.splice(r.cursor, 1); - const sei = adjustIndex(r.suspendedEditingIndex, r.cursor, 1, 0, 0); - return { - ...r, - glyphs: next, - suspendedEditingIndex: sei, - suspendedEditingGlyph: sei !== null ? r.suspendedEditingGlyph : null, - }; - }); - return true; - } - - seed(glyph: GlyphRef): void { - const r = this.#peek(); - if (r.glyphs.length > 0) return; - this.#set({ ...r, glyphs: [glyph], cursor: 1, anchor: 1 }); - } - - clear(): void { - this.#set(EMPTY_RUN); - } - - clearAll(): void { - this.#runs.clear(); - } - - setFont(font: Font): void { - this.#$font.set(font); - } - - setOriginX(x: number): void { - this.#update((r) => ({ ...r, originX: x })); - } - - setCursorVisible(visible: boolean): void { - this.#update((r) => ({ ...r, cursorVisible: visible })); - } - - /** @knipclassignore — used via editor.textRunController in HiddenTextInput */ - getCodepoints(): number[] { - return this.#peek() - .glyphs.map((ref) => ref.unicode) - .filter((unicode): unicode is number => unicode !== null); - } - - setOwnerGlyph(glyph: GlyphRef | null): void { - const nextKey = glyph ? glyph.glyphName : DEFAULT_TEXT_RUN_KEY; - if (nextKey === this.#$activeKey.peek()) return; - this.#$activeKey.set(nextKey); - this.#update((r) => ({ - ...r, - hoveredIndex: null, - inspectionSlotIndex: null, - inspectionHoveredComponentIndex: null, - })); - } - - setEditingSlot(index: number | null, glyph: GlyphRef | null = null): void { - this.#update((r) => ({ ...r, editingIndex: index, editingGlyph: glyph })); - } - - resetEditingContext(): void { - this.#update((r) => ({ - ...r, - editingIndex: null, - editingGlyph: null, - suspendedEditingIndex: null, - suspendedEditingGlyph: null, - hoveredIndex: null, - inspectionSlotIndex: null, - inspectionHoveredComponentIndex: null, - })); - } - - suspendEditing(): void { - this.#update((r) => ({ - ...r, - suspendedEditingIndex: r.editingIndex, - suspendedEditingGlyph: r.editingGlyph, - editingIndex: null, - editingGlyph: null, - hoveredIndex: null, - inspectionSlotIndex: null, - inspectionHoveredComponentIndex: null, - })); - } - - resumeEditing(): { index: number; glyph: GlyphRef } | null { - const r = this.#peek(); - const index = r.suspendedEditingIndex; - const glyph = r.suspendedEditingGlyph; - this.#update((r) => ({ - ...r, - editingIndex: r.suspendedEditingIndex, - editingGlyph: r.suspendedEditingGlyph, - suspendedEditingIndex: null, - suspendedEditingGlyph: null, - })); - if (index === null || glyph === null) return null; - return { index, glyph }; - } - - setHovered(index: number | null): void { - this.#update((r) => ({ - ...r, - hoveredIndex: index, - inspectionHoveredComponentIndex: - r.inspectionSlotIndex !== index ? null : r.inspectionHoveredComponentIndex, - })); - } - - setInspectionSlot(index: number | null): void { - this.#update((r) => ({ - ...r, - inspectionSlotIndex: index, - inspectionHoveredComponentIndex: null, - })); - } - - setInspectionHoveredComponent(index: number | null): void { - this.#update((r) => ({ - ...r, - inspectionHoveredComponentIndex: r.inspectionSlotIndex === null ? null : index, - })); - } - - clearInspection(): void { - this.#update((r) => ({ - ...r, - inspectionSlotIndex: null, - inspectionHoveredComponentIndex: null, - })); - } - - snapshot(): TextRunSnapshot { - const r = this.#peek(); - return { - glyphs: [...r.glyphs], - cursor: r.cursor, - anchor: r.anchor, - originX: r.originX, - editingIndex: r.editingIndex, - editingGlyph: r.editingGlyph, - }; - } - - restore(snapshot: TextRunSnapshot): void { - this.#set({ - ...this.#peek(), - glyphs: [...snapshot.glyphs], - cursor: snapshot.cursor, - anchor: snapshot.anchor, - originX: snapshot.originX, - editingIndex: snapshot.editingIndex, - editingGlyph: snapshot.editingGlyph, - }); - } - - exportRuns(): Record { - const out: Record = {}; - for (const [key, $run] of this.#runs.entries()) { - if (key === DEFAULT_TEXT_RUN_KEY) continue; - const run = $run.peek(); - if (run.glyphs.length === 0) continue; - out[key] = { - glyphs: [...run.glyphs], - cursorPosition: run.cursor, - originX: run.originX, - editingIndex: run.editingIndex, - editingGlyph: run.editingGlyph, - }; - } - return out; - } - - hydrateRuns(next: Record): void { - this.#runs.clear(); - for (const [glyphKey, persisted] of Object.entries(next)) { - this.#runs.set( - glyphKey, - signal({ - ...EMPTY_RUN, - glyphs: [...persisted.glyphs], - cursor: persisted.cursorPosition, - anchor: persisted.cursorPosition, - originX: persisted.originX, - editingIndex: persisted.editingIndex, - editingGlyph: persisted.editingGlyph, - }), - ); - } - - const key = this.#$activeKey.peek(); - if (!this.#runs.has(key)) { - this.#runs.set(key, signal(EMPTY_RUN)); - } - } - - #signal(): WritableSignal { - const key = this.#$activeKey.peek(); - let $r = this.#runs.get(key); - if ($r) return $r; - $r = signal(EMPTY_RUN); - this.#runs.set(key, $r); - return $r; - } - - #peek(): RunState { - const key = this.#$activeKey.peek(); - const $r = this.#runs.get(key); - return $r ? $r.peek() : EMPTY_RUN; - } - - #set(next: RunState): void { - this.#signal().set(next); - } - - #update(fn: (current: RunState) => RunState): void { - const $r = this.#signal(); - const next = fn($r.peek()); - $r.set(next); - } - - #getCursorLineY(): number | null { - const state = this.#$state.peek(); - const font = this.#$font.peek(); - if (!state || !font) return null; - - const r = this.#peek(); - const slots = state.layout.slots; - if (slots.length === 0) return null; - - const prevSlot = r.cursor > 0 ? slots[r.cursor - 1] : null; - - if (prevSlot && prevSlot.unicode === 10) { - const metrics = font.getMetrics(); - const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); - return prevSlot.y - lineHeight; - } - - return (prevSlot ?? slots[0])?.y ?? 0; - } - - #deriveRenderState(): TextRunRenderState | null { - this.#$activeKey.value; // track active key changes - const r = this.#signal().value; // track run state changes (creates signal if needed) - const font = this.#$font.value; // track font changes - if (!font) return null; - - const layout = - r.glyphs.length > 0 - ? computeTextLayout([...r.glyphs], { x: r.originX, y: 0 }, font) - : { slots: [], totalAdvance: 0 }; - - if (r.glyphs.length === 0 && !r.cursorVisible) return null; - - const sel = - r.anchor !== r.cursor - ? { start: Math.min(r.anchor, r.cursor), end: Math.max(r.anchor, r.cursor) } - : null; - const metrics = font.getMetrics(); - const selectionRects = sel ? computeSelectionRects(layout.slots, sel, metrics) : []; - - const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); - const cursorPos = r.cursorVisible && !sel ? computeCursorPosition(r, layout, lineHeight) : null; - - const compositeInspection = - r.inspectionSlotIndex !== null - ? { - slotIndex: r.inspectionSlotIndex, - hoveredComponentIndex: r.inspectionHoveredComponentIndex, - } - : null; - - return { - layout, - cursorX: cursorPos?.x ?? null, - cursorY: cursorPos?.y ?? 0, - selection: sel, - selectionRects, - editingIndex: r.editingIndex, - editingGlyph: r.editingGlyph, - hoveredIndex: r.hoveredIndex, - compositeInspection, - }; - } -} - -function deleteRange(r: RunState): { glyphs: GlyphRef[]; cursor: number } { - if (r.anchor === r.cursor) return { glyphs: [...r.glyphs], cursor: r.cursor }; - const start = Math.min(r.anchor, r.cursor); - const end = Math.max(r.anchor, r.cursor); - const glyphs = [...r.glyphs]; - glyphs.splice(start, end - start); - return { glyphs, cursor: start }; -} - -function computeCursorPosition( - r: RunState, - layout: TextLayout, - lineHeight: number, -): { x: number; y: number } | null { - if (!r.cursorVisible) return null; - - if (r.cursor === 0) { - const firstSlot = layout.slots[0]; - return { x: r.originX, y: firstSlot?.y ?? 0 }; - } - - if (r.cursor <= layout.slots.length) { - const prevSlot = layout.slots[r.cursor - 1]; - if (!prevSlot) return { x: r.originX, y: 0 }; - - if (prevSlot.unicode === 10) { - return { x: r.originX, y: prevSlot.y - lineHeight }; - } - - return { x: prevSlot.x + prevSlot.advance, y: prevSlot.y }; - } - - const lastSlot = layout.slots[layout.slots.length - 1]; - if (!lastSlot) return { x: r.originX, y: 0 }; - - if (lastSlot.unicode === 10) { - return { x: r.originX, y: lastSlot.y - lineHeight }; - } - - return { x: lastSlot.x + lastSlot.advance, y: lastSlot.y }; -} - -function computeSelectionRects( - slots: GlyphSlot[], - selection: SelectionRange, - metrics: FontMetrics, -): SelectionRect[] { - const rects: SelectionRect[] = []; - - for (let i = selection.start; i < selection.end; i++) { - const slot = slots[i]; - if (!slot) continue; - rects.push({ - x: slot.x, - width: Math.max(slot.advance, 0), - top: slot.y + metrics.ascender, - bottom: slot.y + metrics.descender, - }); - } - - return mergeAdjacentRects(rects); -} - -function mergeAdjacentRects(rects: SelectionRect[]): SelectionRect[] { - if (rects.length <= 1) return rects; - - const merged: SelectionRect[] = [rects[0]]; - - for (let i = 1; i < rects.length; i++) { - const prev = merged[merged.length - 1]; - const curr = rects[i]; - const prevEnd = prev.x + prev.width; - - if (Math.abs(prevEnd - curr.x) < 0.5 && prev.top === curr.top && prev.bottom === curr.bottom) { - prev.width = curr.x + curr.width - prev.x; - } else { - merged.push(curr); - } - } - - return merged; -} - -function isWhitespace(glyph: GlyphRef): boolean { - if (glyph.unicode === null) return false; - return glyph.unicode === 32 || glyph.unicode === 9 || glyph.unicode === 10; -} - -function isPunctuation(glyph: GlyphRef): boolean { - if (glyph.unicode === null) return false; - const cp = glyph.unicode; - return ( - (cp >= 0x21 && cp <= 0x2f) || // !"#$%&'()*+,-./ - (cp >= 0x3a && cp <= 0x40) || // :;<=>?@ - (cp >= 0x5b && cp <= 0x60) || // [\]^_` - (cp >= 0x7b && cp <= 0x7e) // {|}~ - ); -} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts new file mode 100644 index 00000000..c45d1394 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts @@ -0,0 +1,107 @@ +/** + * TextRuns — per-glyph store of TextRun instances. + * + * One TextRun per glyph name (each glyph carries its own typing context + * across edit sessions). Plus a default-active run keyed by `__default__` + * for cases where no specific glyph owns the run yet. + * + * Active run is selected via `switchTo(glyphName | null)`, which returns + * the now-active run for ergonomic chaining. + */ +import { + signal, + computed, + type Signal, + type WritableSignal, + type ComputedSignal, +} from "@/lib/reactive/signal"; +import { TextRun } from "./TextRun"; +import type { Positioner } from "./layout"; +import type { Font } from "@/lib/model/Font"; +import type { TextBufferSnapshot } from "./TextBuffer"; + +const DEFAULT_RUN_KEY = "__default__"; + +export interface PersistedTextRun { + buffer: TextBufferSnapshot; +} + +export class TextRuns { + readonly #runs: Map; + readonly #$activeKey: WritableSignal; + readonly #$active: ComputedSignal; + readonly #font: Font; + readonly #positioner: Positioner; + + constructor(font: Font, positioner: Positioner) { + this.#runs = new Map(); + this.#$activeKey = signal(DEFAULT_RUN_KEY); + this.#font = font; + this.#positioner = positioner; + this.#$active = computed(() => this.#getOrCreate(this.#$activeKey.value)); + } + + /** The currently-active run. Lazily creates one for the active key if needed. */ + get active(): TextRun { + return this.#$active.value; + } + + /** Reactive view of the active run — fires on `switchTo`. */ + get $active(): Signal { + return this.#$active; + } + + /** + * Switch active run to the one keyed by `glyphName` (or default if null). + * Returns the now-active run for chaining. + */ + switchTo(glyphName: string | null): TextRun { + const key = glyphName ?? DEFAULT_RUN_KEY; + this.#$activeKey.set(key); + return this.active; + } + + /** @knipclassignore — used by tool deactivation paths (TODO) */ + clear(): void { + const run = this.active; + run.buffer.clear(); + run.interaction.clear(); + } + + /** Drop every run. */ + clearAll(): void { + this.#runs.clear(); + } + + serialize(): Record { + const out: Record = {}; + for (const [key, run] of this.#runs) { + if (key === DEFAULT_RUN_KEY) continue; + const buffer = run.buffer.snapshot(); + if (buffer.cells && buffer.cells.length > 0) { + out[key] = { buffer }; + } + } + return out; + } + + deserialize(persisted: Record): void { + this.#runs.clear(); + for (const [key, entry] of Object.entries(persisted)) { + const run = new TextRun(this.#font, this.#positioner); + run.buffer.restore(entry.buffer); + this.#runs.set(key, run); + } + if (!this.#runs.has(this.#$activeKey.peek())) { + this.#$activeKey.set(DEFAULT_RUN_KEY); + } + } + + #getOrCreate(key: string): TextRun { + let run = this.#runs.get(key); + if (run) return run; + run = new TextRun(this.#font, this.#positioner); + this.#runs.set(key, run); + return run; + } +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts index 39f20856..eefc8d29 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts @@ -1,25 +1,21 @@ -import type { ToolContext } from "../../core/Behavior"; import type { ToolEventOf } from "../../core/GestureDetector"; +import type { ToolContext } from "../../core/Behavior"; import type { TextBehavior, TextState } from "../types"; /** - * Handles tool-level keyboard events for the text tool. - * - * Character input, arrows, backspace, clipboard, and IME are handled by the - * hidden textarea (HiddenTextInput component). This behavior only handles - * events that affect tool state (Escape to exit). + * Minimal typing behavior. Real keyboard input flows through + * `HiddenTextInput.tsx` directly to `editor.textRun.{insert,delete,...}`; + * this behavior exists so the Text tool has a registered behavior slot + * (state-machine compliance) and can intercept Escape via the tool layer + * if needed. */ export class TypingBehavior implements TextBehavior { onKeyDown(state: TextState, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean { if (state.type !== "typing") return false; - - switch (event.key) { - case "Escape": - ctx.setState({ type: "idle" }); - ctx.editor.setActiveTool("select"); - return true; - default: - return false; + if (event.key === "Escape") { + ctx.editor.setActiveTool("select"); + return true; } + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout.test.ts deleted file mode 100644 index 267a6f3d..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { Font } from "@/lib/model/Font"; -import type { Bounds } from "@shift/geo"; -import { computeTextLayout, hitTestTextSlot, hitTestTextCaret, type GlyphRef } from "./layout"; -import { expectAt } from "@/testing"; - -function createMockFont( - advances: Record = {}, - svgPaths: Record = {}, - bboxes: Record = {}, -): Font { - const glyphNameMap: Record = {}; - for (const unicode of [ - ...Object.keys(advances), - ...Object.keys(svgPaths), - ...Object.keys(bboxes), - ]) { - const u = Number(unicode); - glyphNameMap[u] = `uni${u.toString(16).toUpperCase()}`; - } - - // NOTE: this parallel-world Font mock is the exact pattern the - // /writing-tests skill bans — tracked for proper rewrite in - // projects/shift/text-layout-rethink.md. Kept narrow (only the methods - // computeTextLayout + hitTestTextSlot/Caret actually call) until the - // layout API is reshaped. - return { - getMetrics: () => ({ - unitsPerEm: 1000, - ascender: 800, - descender: -200, - capHeight: 700, - xHeight: 500, - lineGap: 0, - italicAngle: null, - underlinePosition: null, - underlineThickness: null, - }), - nameForUnicode: (unicode: number) => glyphNameMap[unicode] ?? null, - getPath: (name: string) => { - const unicode = Object.entries(glyphNameMap).find(([, n]) => n === name)?.[0]; - if (!unicode) return null; - const svgPath = svgPaths[Number(unicode)] ?? null; - return svgPath ? new Path2D(svgPath) : null; - }, - glyph: (name: string) => { - const unicode = Object.entries(glyphNameMap).find(([, n]) => n === name)?.[0]; - if (!unicode) return null; - const advance = advances[Number(unicode)]; - if (advance === undefined) return null; - return { advance } as unknown as { advance: number }; - }, - getBbox: (name: string) => { - const unicode = Object.entries(glyphNameMap).find(([, n]) => n === name)?.[0]; - if (!unicode) return null; - return bboxes[Number(unicode)] ?? null; - }, - getSvgPath: (name: string) => { - const unicode = Object.entries(glyphNameMap).find(([, n]) => n === name)?.[0]; - if (!unicode) return null; - return svgPaths[Number(unicode)] ?? null; - }, - } as unknown as Font; -} - -function toGlyphs(unicodes: number[]): GlyphRef[] { - return unicodes.map((unicode) => ({ - glyphName: `uni${unicode.toString(16).toUpperCase()}`, - unicode, - })); -} - -describe("computeTextLayout", () => { - it("should return empty layout for empty codepoints", () => { - const font = createMockFont(); - const layout = computeTextLayout([], { x: 0, y: 0 }, font); - expect(layout.slots).toEqual([]); - expect(layout.totalAdvance).toBe(0); - }); - - it("should compute positioned slots with accumulated advances", () => { - const font = createMockFont({ - 72: 600, // H - 101: 500, // e - 108: 250, // l - }); - - const layout = computeTextLayout(toGlyphs([72, 101, 108]), { x: 100, y: 0 }, font); - - expect(layout.slots).toHaveLength(3); - expect(expectAt(layout.slots, 0).x).toBe(100); - expect(expectAt(layout.slots, 0).advance).toBe(600); - expect(expectAt(layout.slots, 1).x).toBe(700); - expect(expectAt(layout.slots, 1).advance).toBe(500); - expect(expectAt(layout.slots, 2).x).toBe(1200); - expect(expectAt(layout.slots, 2).advance).toBe(250); - expect(layout.totalAdvance).toBe(1350); - }); - - it("should use 0 advance for missing glyphs", () => { - const font = createMockFont(); - const layout = computeTextLayout(toGlyphs([65]), { x: 0, y: 0 }, font); - - expect(expectAt(layout.slots, 0).advance).toBe(0); - expect(layout.totalAdvance).toBe(0); - }); -}); - -describe("computeTextLayout (multi-line)", () => { - it("should place slots on the next line after a newline", () => { - const font = createMockFont({ 72: 600, 101: 500 }); - // H, newline, e - const glyphs: GlyphRef[] = [ - ...toGlyphs([72]), - { glyphName: ".newline", unicode: 10 }, - ...toGlyphs([101]), - ]; - const layout = computeTextLayout(glyphs, { x: 0, y: 0 }, font); - - expect(layout.slots).toHaveLength(3); - // Line 1: H at y=0 - expect(expectAt(layout.slots, 0).y).toBe(0); - // Newline slot at y=0 - expect(expectAt(layout.slots, 1).y).toBe(0); - // Line 2: e at y=-1000 (lineHeight = 800 - (-200) + 0 = 1000) - expect(expectAt(layout.slots, 2).y).toBe(-1000); - expect(expectAt(layout.slots, 2).x).toBe(0); - }); -}); - -describe("hitTestTextSlot", () => { - const font = createMockFont(); - const metrics = { - unitsPerEm: 1000, - ascender: 800, - descender: -200, - capHeight: 700, - xHeight: 500, - lineGap: 0, - italicAngle: null, - underlinePosition: null, - underlineThickness: null, - }; - - it("should return null for empty layout", () => { - const layout = { slots: [], totalAdvance: 0 }; - expect(hitTestTextSlot(layout, { x: 50, y: 400 }, metrics, font)).toBeNull(); - }); - - it("should return null when y is above ascender", () => { - const font = createMockFont({ 65: 500 }); - const layout = computeTextLayout(toGlyphs([65]), { x: 0, y: 0 }, font); - expect(hitTestTextSlot(layout, { x: 250, y: 900 }, metrics, font)).toBeNull(); - }); - - it("should return null when y is below descender", () => { - const font = createMockFont({ 65: 500 }); - const layout = computeTextLayout(toGlyphs([65]), { x: 0, y: 0 }, font); - expect(hitTestTextSlot(layout, { x: 250, y: -300 }, metrics, font)).toBeNull(); - }); - - it("should return slot index only when point is inside slot bounds", () => { - const font = createMockFont({ - 72: 600, // H: 0-600 - 101: 500, // e: 600-1100 - 108: 250, // l: 1100-1350 - }); - const layout = computeTextLayout(toGlyphs([72, 101, 108]), { x: 0, y: 0 }, font); - - expect(hitTestTextSlot(layout, { x: 100, y: 400 }, metrics, font)).toBe(0); - expect(hitTestTextSlot(layout, { x: 400, y: 400 }, metrics, font)).toBe(0); - expect(hitTestTextSlot(layout, { x: 700, y: 400 }, metrics, font)).toBe(1); - expect(hitTestTextSlot(layout, { x: 1400, y: 400 }, metrics, font)).toBeNull(); - }); - - it("shape hit test should reject points outside glyph bbox before path hit", () => { - // Bbox pre-check is an optimization contract: we must not invoke the - // expensive path hit tester when the point is clearly outside the glyph - // bbox. Count invocations to verify the optimization holds. - let hitPathCalls = 0; - const pathHitTester = { - hitPath: () => { - hitPathCalls++; - return true; - }, - }; - - const font = createMockFont( - { 65: 500 }, - { 65: "M0 0L100 100" }, - { - 65: { - min: { x: 10, y: 0 }, - max: { x: 100, y: 100 }, - }, - }, - ); - const layout = computeTextLayout(toGlyphs([65]), { x: 0, y: 0 }, font); - - const result = hitTestTextSlot(layout, { x: 5, y: 50 }, metrics, font, { - outlineRadius: 0, - includeFill: true, - requireShape: true, - pathHitTester, - }); - - expect(result).toBeNull(); - expect(hitPathCalls).toBe(0); - }); - - it("shape hit test should allow zero-advance combining marks by glyph bounds", () => { - const combiningAcute = 0x0301; - const letterA = 0x0061; - const spacingAcute = 0x00b4; - - let hitPathCalls = 0; - const pathHitTester = { - hitPath: (_path: Path2D, x: number, y: number, _strokeWidth: number, _fill: boolean) => { - hitPathCalls++; - return x >= 40 && x <= 120 && y >= 0 && y <= 100; - }, - }; - - const font = createMockFont( - { - [combiningAcute]: 0, - [letterA]: 500, - [spacingAcute]: 300, - }, - { - [combiningAcute]: "M40 0L120 0L120 100L40 100Z", - }, - { - [combiningAcute]: { - min: { x: 40, y: 0 }, - max: { x: 120, y: 100 }, - }, - }, - ); - const layout = computeTextLayout( - toGlyphs([combiningAcute, letterA, spacingAcute]), - { x: 0, y: 0 }, - font, - ); - - expect( - hitTestTextSlot(layout, { x: 80, y: 50 }, metrics, font, { - outlineRadius: 2, - includeFill: true, - requireShape: true, - pathHitTester, - }), - ).toBe(0); - - expect(hitPathCalls).toBeGreaterThan(0); - }); - - it("should hit slots on the second line", () => { - const font = createMockFont({ 72: 600, 101: 500 }); - // H, newline, e — "e" is on line 2 at y=-1000 - const glyphs: GlyphRef[] = [ - ...toGlyphs([72]), - { glyphName: ".newline", unicode: 10 }, - ...toGlyphs([101]), - ]; - const layout = computeTextLayout(glyphs, { x: 0, y: 0 }, font); - - // Click on line 2 (y=-500 is between descender -1200 and ascender -200) - expect(hitTestTextSlot(layout, { x: 250, y: -500 }, metrics, font)).toBe(2); - - // Click on line 1 still works - expect(hitTestTextSlot(layout, { x: 100, y: 400 }, metrics, font)).toBe(0); - }); - - it("should not match line 1 slot when clicking on line 2 Y", () => { - const font = createMockFont({ 72: 600, 101: 500 }); - const glyphs: GlyphRef[] = [ - ...toGlyphs([72]), - { glyphName: ".newline", unicode: 10 }, - ...toGlyphs([101]), - ]; - const layout = computeTextLayout(glyphs, { x: 0, y: 0 }, font); - - // x=100 is within H's advance, but y=-500 is on line 2 — should not hit H - expect(hitTestTextSlot(layout, { x: 100, y: -500 }, metrics, font)).not.toBe(0); - }); - - it("shape hit test should return slot only when path hit succeeds", () => { - let hitPathCalls = 0; - const pathHitTester = { - hitPath: (_path: Path2D, x: number, y: number, _strokeWidth: number, _fill: boolean) => { - hitPathCalls++; - return x >= 20 && x <= 80 && y >= 10 && y <= 90; - }, - }; - - const font = createMockFont( - { 65: 500 }, - { 65: "M0 0L100 100" }, - { - 65: { - min: { x: 0, y: 0 }, - max: { x: 100, y: 100 }, - }, - }, - ); - const layout = computeTextLayout(toGlyphs([65]), { x: 0, y: 0 }, font); - - expect( - hitTestTextSlot(layout, { x: 15, y: 50 }, metrics, font, { - outlineRadius: 2, - includeFill: true, - requireShape: true, - pathHitTester, - }), - ).toBeNull(); - - expect( - hitTestTextSlot(layout, { x: 50, y: 50 }, metrics, font, { - outlineRadius: 2, - includeFill: true, - requireShape: true, - pathHitTester, - }), - ).toBe(0); - - expect(hitPathCalls).toBeGreaterThan(0); - }); -}); - -describe("hitTestTextCaret", () => { - const metrics = { - unitsPerEm: 1000, - ascender: 800, - descender: -200, - capHeight: 700, - xHeight: 500, - lineGap: 0, - italicAngle: null, - underlinePosition: null, - underlineThickness: null, - }; - - it("should return caret insertion index using slot midpoints", () => { - const font = createMockFont({ - 72: 600, // H: 0-600 - 101: 500, // e: 600-1100 - 108: 250, // l: 1100-1350 - }); - const layout = computeTextLayout(toGlyphs([72, 101, 108]), { x: 0, y: 0 }, font); - - expect(hitTestTextCaret(layout, { x: 100, y: 400 }, metrics)).toBe(0); - expect(hitTestTextCaret(layout, { x: 400, y: 400 }, metrics)).toBe(1); - expect(hitTestTextCaret(layout, { x: 700, y: 400 }, metrics)).toBe(1); - expect(hitTestTextCaret(layout, { x: 1400, y: 400 }, metrics)).toBe(3); - }); - - it("should place caret on the correct line in multi-line layout", () => { - const font = createMockFont({ 72: 600, 101: 500 }); - // H, newline, e — slots: [H@0, \n@0, e@-1000] - const glyphs: GlyphRef[] = [ - ...toGlyphs([72]), - { glyphName: ".newline", unicode: 10 }, - ...toGlyphs([101]), - ]; - const layout = computeTextLayout(glyphs, { x: 0, y: 0 }, font); - - // Click on line 2 before "e" midpoint (midX = 250) — caret before "e" (index 2) - expect(hitTestTextCaret(layout, { x: 100, y: -500 }, metrics)).toBe(2); - - // Click on line 2 after "e" midpoint — caret after "e" (index 3) - expect(hitTestTextCaret(layout, { x: 400, y: -500 }, metrics)).toBe(3); - - // Click on line 1 before "H" midpoint — caret before "H" (index 0) - expect(hitTestTextCaret(layout, { x: 100, y: 400 }, metrics)).toBe(0); - }); - - it("should return null for clicks far below all lines", () => { - const font = createMockFont({ 72: 600 }); - const layout = computeTextLayout(toGlyphs([72]), { x: 0, y: 0 }, font); - - expect(hitTestTextCaret(layout, { x: 100, y: -2000 }, metrics)).toBeNull(); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout.ts deleted file mode 100644 index 9b03e2c7..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { Point2D, FontMetrics } from "@shift/types"; -import type { Bounds } from "@shift/geo"; -import type { Font } from "@/lib/model/Font"; -import { displayAdvance } from "@/lib/utils/unicode"; - -export interface GlyphRef { - glyphName: string; - unicode: number | null; -} - -export interface GlyphSlot { - glyph: GlyphRef; - unicode: number | null; - x: number; - y: number; - advance: number; - bounds: Bounds | null; -} - -const NEWLINE_GLYPH_NAME = ".newline"; - -export interface TextLayout { - slots: GlyphSlot[]; - totalAdvance: number; -} - -export interface TextPathHitTester { - hitPath(path: Path2D, x: number, y: number, strokeWidth: number, includeFill: boolean): boolean; -} - -export interface TextSlotHitTestOptions { - /** Outline hit radius in UPM units. */ - outlineRadius?: number; - /** Include interior fill in addition to outline stroke checks. */ - includeFill?: boolean; - /** - * When true, require an actual glyph shape hit. If false, can fall back to - * slot-box hit when shape data or a hit tester is unavailable. - */ - requireShape?: boolean; - /** Optional override for tests or custom hit-testing backends. */ - pathHitTester?: TextPathHitTester | null; -} - -class CanvasTextPathHitTester implements TextPathHitTester { - #ctx: CanvasRenderingContext2D | null; - - constructor(ctx: CanvasRenderingContext2D | null) { - this.#ctx = ctx; - } - - hitPath(path: Path2D, x: number, y: number, strokeWidth: number, includeFill: boolean): boolean { - if (!this.#ctx) return false; - - this.#ctx.save(); - this.#ctx.lineWidth = Math.max(strokeWidth, Number.EPSILON); - const onStroke = this.#ctx.isPointInStroke(path, x, y); - const onFill = includeFill && this.#ctx.isPointInPath(path, x, y); - this.#ctx.restore(); - - return onStroke || onFill; - } -} - -let defaultTextPathHitTester: TextPathHitTester | null | undefined; - -function getDefaultTextPathHitTester(): TextPathHitTester | null { - if (defaultTextPathHitTester !== undefined) { - return defaultTextPathHitTester; - } - if (typeof document === "undefined") { - defaultTextPathHitTester = null; - return defaultTextPathHitTester; - } - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - defaultTextPathHitTester = new CanvasTextPathHitTester(ctx); - return defaultTextPathHitTester; -} - -export function computeTextLayout(glyphs: GlyphRef[], origin: Point2D, font: Font): TextLayout { - const slots: GlyphSlot[] = []; - const metrics = font.getMetrics(); - const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); - let x = origin.x; - let y = 0; - - for (const ref of glyphs) { - if (ref.glyphName === NEWLINE_GLYPH_NAME || ref.unicode === 10) { - slots.push({ glyph: ref, unicode: 10, x, y, advance: 0, bounds: null }); - x = origin.x; - y -= lineHeight; - continue; - } - - const glyph = font.glyph(ref.glyphName); - const rawAdvance = glyph?.advance ?? 0; - const advance = resolveEditorAdvance(ref, rawAdvance); - - slots.push({ - glyph: ref, - unicode: ref.unicode, - x, - y, - advance, - bounds: font.getBbox(ref.glyphName) ?? null, - }); - - x += advance; - } - - return { - slots, - totalAdvance: x - origin.x, - }; -} - -export { NEWLINE_GLYPH_NAME }; - -function resolveEditorAdvance(glyph: GlyphRef, advance: number): number { - return displayAdvance(advance, glyph.glyphName, glyph.unicode); -} - -function isWithinSlotVerticalBounds( - slot: GlyphSlot, - y: number, - metrics: FontMetrics, - padding: number, -): boolean { - const top = slot.y + metrics.ascender + padding; - const bottom = slot.y + metrics.descender - padding; - return y <= top && y >= bottom; -} - -function isWithinSlotAdvance( - slot: GlyphSlot, - index: number, - totalSlots: number, - x: number, - padding: number, -): boolean { - const effectiveAdvance = Math.max(slot.advance, 0); - const startX = slot.x - padding; - const endX = slot.x + effectiveAdvance + padding; - const isLastSlot = index === totalSlots - 1; - - if (effectiveAdvance <= 0) { - return Math.abs(x - slot.x) <= padding; - } - - if (isLastSlot) { - return x >= startX && x <= endX; - } - - return x >= startX && x < endX; -} - -function isWithinSlotGlyphBounds(slot: GlyphSlot, pos: Point2D, padding: number): boolean { - if (!slot.bounds) return true; - - const minX = slot.x + slot.bounds.min.x - padding; - const maxX = slot.x + slot.bounds.max.x + padding; - const minY = slot.y + slot.bounds.min.y - padding; - const maxY = slot.y + slot.bounds.max.y + padding; - - return pos.x >= minX && pos.x <= maxX && pos.y >= minY && pos.y <= maxY; -} - -function isWithinSlotBoundsX(slot: GlyphSlot, x: number, padding: number): boolean { - if (!slot.bounds) return false; - - const minX = slot.x + slot.bounds.min.x - padding; - const maxX = slot.x + slot.bounds.max.x + padding; - return x >= minX && x <= maxX; -} - -/** - * Returns the glyph slot under the pointer, or null when outside slot bounds. - * Uses staged filtering: - * 1) slot advance box - * 2) glyph bbox (if available) - * 3) optional glyph-shape hit test (stroke and/or fill) - */ -export function hitTestTextSlot( - layout: TextLayout, - pos: Point2D, - metrics: FontMetrics, - font: Font, - options: TextSlotHitTestOptions = {}, -): number | null { - const { slots } = layout; - if (slots.length === 0) return null; - - const outlineRadius = Math.max(options.outlineRadius ?? 0, 0); - const includeFill = options.includeFill ?? false; - const requireShape = options.requireShape ?? false; - const pathHitTester = - options.pathHitTester === undefined ? getDefaultTextPathHitTester() : options.pathHitTester; - - for (const [i, slot] of slots.entries()) { - if (!isWithinSlotVerticalBounds(slot, pos.y, metrics, outlineRadius)) continue; - - const withinAdvance = isWithinSlotAdvance(slot, i, slots.length, pos.x, outlineRadius); - - if (requireShape && slot.bounds) { - const withinBoundsX = isWithinSlotBoundsX(slot, pos.x, outlineRadius); - if (!withinAdvance && !withinBoundsX) { - continue; - } - } else if (!withinAdvance) { - continue; - } - - if (!isWithinSlotGlyphBounds(slot, pos, outlineRadius)) { - continue; - } - - const slotPath = font.getPath(slot.glyph.glyphName); - if (slotPath && pathHitTester) { - const hit = pathHitTester.hitPath( - slotPath, - pos.x - slot.x, - pos.y - slot.y, - Math.max(outlineRadius * 2, Number.EPSILON), - includeFill, - ); - if (hit) return i; - continue; - } - - if (!requireShape) { - return i; - } - } - - return null; -} - -/** - * Returns caret insertion index using midpoint partitioning. - * Can return `layout.slots.length` when after the final slot midpoint. - * - * For multi-line layouts, finds the closest line by Y distance - * and partitions only among slots on that line. - */ -export function hitTestTextCaret( - layout: TextLayout, - pos: Point2D, - metrics: FontMetrics, -): number | null { - const { slots } = layout; - if (slots.length === 0) return null; - - const lineY = findClosestLineY(slots, pos.y, metrics); - if (lineY === null) return null; - - // Check the click is within reasonable vertical distance of the line - const lineHeight = metrics.ascender - metrics.descender; - const top = lineY + metrics.ascender; - const bottom = lineY + metrics.descender; - if (pos.y > top + lineHeight / 2 || pos.y < bottom - lineHeight / 2) return null; - - // Midpoint partitioning among slots on this line only - for (const [i, slot] of slots.entries()) { - if (slot.y !== lineY) continue; - - const midX = slot.x + slot.advance / 2; - if (pos.x < midX) return i; - } - - // After the last slot on this line - for (let i = slots.length - 1; i >= 0; i--) { - if (slots[i].y === lineY) return i + 1; - } - - return slots.length; -} - -function findClosestLineY(slots: GlyphSlot[], y: number, metrics: FontMetrics): number | null { - let bestY: number | null = null; - let bestDist = Infinity; - - for (const slot of slots) { - const lineCenter = slot.y + (metrics.ascender + metrics.descender) / 2; - const dist = Math.abs(y - lineCenter); - if (dist < bestDist) { - bestDist = dist; - bestY = slot.y; - } - } - - return bestY; -} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts new file mode 100644 index 00000000..93a1784e --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Font } from "@/lib/model/Font"; +import { Caret } from "./Caret"; +import { glyphCell as glyph, linebreak } from "./types"; +import { loadTestFont, makeLayout } from "./testUtils"; + +describe("Caret", () => { + let font: Font; + + beforeEach(() => { + font = loadTestFont(); + }); + + // atCluster preserves the cluster index. + it("atCluster returns a caret at the requested cluster", () => { + const layout = makeLayout([glyph("A", 65), glyph("B", 66)], font); + + expect(Caret.atCluster(layout, 0).cluster).toBe(0); + expect(Caret.atCluster(layout, 1).cluster).toBe(1); + }); + + // next advances by one cluster within a paragraph. + it("next advances by one cluster", () => { + const layout = makeLayout([glyph("A", 65), glyph("B", 66)], font); + const c0 = Caret.atCluster(layout, 0); + + expect(c0.next().cluster).toBe(1); + expect(c0.next().next().cluster).toBe(2); + }); + + // next at end clamps (does not run past the buffer). + it("next clamps at buffer end", () => { + const layout = makeLayout([glyph("A", 65)], font); + const end = Caret.atCluster(layout, 1); + + expect(end.next().cluster).toBe(1); + }); + + // next steps through paragraph boundary. Buffer = [A, \n, B]. + // Caret 0 (before A) → 1 (end of line 1, before linebreak) + // → 2 (start of line 2, before B) + it("next steps through paragraph boundary", () => { + const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + const metrics = font.getMetrics(); + const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); + let c = Caret.atCluster(layout, 0); + + c = c.next(); + expect(c.cluster).toBe(1); + expect(c.position().y).toBe(0); + + c = c.next(); + expect(c.cluster).toBe(2); + expect(c.position().y).toBe(-lineHeight); + }); + + // previous clamps at buffer start. + it("previous clamps at buffer start", () => { + const layout = makeLayout([glyph("A", 65)], font); + const start = Caret.atCluster(layout, 0); + + expect(start.previous().cluster).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts new file mode 100644 index 00000000..a7ef2a79 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts @@ -0,0 +1,96 @@ +/** + * Immutable caret over a TextLayout. Every navigation method returns a new + * instance, so callers hold carets in signals: every keystroke is + * `$caret.value = $caret.value.next()`. + * + * `cluster` is whole-buffer (matches HarfBuzz's monotonic guarantee). Valid + * clusters are [0, layout.bufferLength]; `next()` and `previous()` clamp. + */ +import type { CaretPosition, Point2D } from "./types"; +import type { TextLayout } from "./TextLayout"; + +export class Caret { + readonly cluster: number; + readonly layout: TextLayout; + + private constructor(layout: TextLayout, cluster: number) { + this.layout = layout; + this.cluster = cluster; + } + + static atCluster(layout: TextLayout, cluster: number): Caret { + const clamped = Math.max(0, Math.min(cluster, layout.bufferLength)); + return new Caret(layout, clamped); + } + + static atPoint(layout: TextLayout, p: Point2D): Caret { + const hit = layout.hitTest(p); + if (!hit) return new Caret(layout, 0); + const cluster = hit.side === "left" ? hit.cluster : hit.cluster + 1; + return Caret.atCluster(layout, cluster); + } + + next(): Caret { + return Caret.atCluster(this.layout, this.cluster + 1); + } + + previous(): Caret { + return Caret.atCluster(this.layout, this.cluster - 1); + } + + /** + * Project the cluster onto canvas coordinates. + * + * 1. If the cluster has a positioned glyph (leading edge) → use it. + * 2. Otherwise (cluster falls on a linebreak or past the last glyph) → + * use the trailing edge of the preceding glyph (cluster - 1). + * 3. If there are no glyphs at all (empty buffer) → origin. + * + * Example with buffer [A, \n, B]: + * + * cluster 0 → pointAt(0) hits A's leading edge { x: origin.x, y: 0 } + * cluster 1 → no glyph; trailing edge of A { x: origin.x + A.adv, y: 0 } + * cluster 2 → pointAt(2) hits B's leading edge { x: origin.x, y: -lineHeight } + * cluster 3 → no glyph; trailing edge of B { x: origin.x + B.adv, y: -lineHeight } + */ + position(): CaretPosition { + const layout = this.layout; + const m = layout.metrics; + const lineHeight = m.ascender - m.descender + (m.lineGap ?? 0); + + const direct = layout.pointAt(this.cluster); + if (direct) return direct; + + for (const line of layout.lines) { + let cursor = layout.origin.x; + for (const run of line.runs) { + for (const g of run.glyphs) { + if (g.cluster + 1 === this.cluster) { + return { x: cursor + g.xAdvance, y: line.y, lineHeight }; + } + cursor += g.xAdvance; + } + } + } + + return { x: layout.origin.x, y: layout.origin.y, lineHeight }; + } + + /** @knipclassignore — vertical nav, deferred to follow-up */ + nextLine(_goalX: number): Caret { + throw new Error("Caret.nextLine not implemented"); + } + + /** @knipclassignore — vertical nav, deferred to follow-up */ + previousLine(_goalX: number): Caret { + throw new Error("Caret.previousLine not implemented"); + } + + /** + * True when the caret sits at a paragraph or buffer boundary. + * @knipclassignore — used by selection extension logic (TODO) + */ + isBoundary(): boolean { + throw new Error("Caret.isBoundary not implemented"); + } +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts new file mode 100644 index 00000000..1675628d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Font } from "@/lib/model/Font"; +import { Positioner } from "./Positioner"; +import { glyphCell as glyph } from "./types"; +import { loadTestFont, ltrRun } from "./testUtils"; + +describe("Positioner", () => { + let font: Font; + + beforeEach(() => { + font = loadTestFont(); + }); + + // Levien invariant: positioned.advance === sum of xAdvance. + it("advance equals sum of xAdvance", () => { + const positioner = new Positioner(); + const run = ltrRun([glyph("A", 65), glyph("B", 66), glyph("C", 67)]); + + const positioned = positioner.position(run, font); + + const sum = positioned.glyphs.reduce((s, g) => s + g.xAdvance, 0); + expect(positioned.advance).toBe(sum); + expect(positioned.advance).toBeGreaterThan(0); + }); + + // cluster numbering honors clusterStart and is monotonic. + it("cluster equals clusterStart + i", () => { + const positioner = new Positioner(); + const run = ltrRun([glyph("A", 65), glyph("B", 66)], /* clusterStart */ 7); + + const positioned = positioner.position(run, font); + + expect(positioned.glyphs.map((g) => g.cluster)).toEqual([7, 8]); + }); + + // Each positioned glyph carries the bounds the font reports. + it("bounds pass through from font.getBbox", () => { + const positioner = new Positioner(); + const run = ltrRun([glyph("A", 65)]); + + const positioned = positioner.position(run, font); + + expect(positioned.glyphs[0].bounds).toEqual(font.getBbox("A")); + }); + + // Glyph not in the font → zero advance, null bounds, no throw. + it("handles missing glyph gracefully", () => { + const positioner = new Positioner(); + const run = ltrRun([glyph("nonexistent-glyph-xyz", 65)]); + + const positioned = positioner.position(run, font); + + expect(positioned.glyphs[0].xAdvance).toBe(0); + expect(positioned.glyphs[0].bounds).toBeNull(); + }); + + // Empty run → empty positioned glyphs, zero advance. + it("empty run yields empty positioned glyphs", () => { + const positioner = new Positioner(); + const run = ltrRun([]); + + const positioned = positioner.position(run, font); + + expect(positioned.glyphs).toEqual([]); + expect(positioned.advance).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts new file mode 100644 index 00000000..81bb5802 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts @@ -0,0 +1,41 @@ +import { displayAdvance } from "@/lib/utils/unicode"; +import type { GlyphCell, PositionedRun, SegmentedRun } from "./types"; +import { Font } from "@/lib/model/Font"; + +/** + * No-shape positioner — literal LTR advance walk, `cluster = clusterStart + i`. + * Permanent product mode for editing scripts where the user wants source-order + * glyph display without joining/contextual substitution (e.g. Arabic + * side-by-side editing). + * + * + */ +export class Positioner { + position(run: SegmentedRun, font: Font): PositionedRun { + let totalAdvance = 0; + + const glyphs = run.glyphs.map((g, idx) => { + const glyph = font.glyph(g.glyphName); + const xAdvance = resolveAdvance(g, font); + totalAdvance += xAdvance; + + return { + glyphName: glyph?.name ?? g.glyphName, + xAdvance, + yAdvance: 0, + xOffset: 0, + yOffset: 0, + cluster: run.clusterStart + idx, + bounds: glyph?.bounds ?? null, + }; + }); + + return { ...run, glyphs, advance: totalAdvance }; + } +} + +/** Resolve a glyph cell to its display advance (handles invisibles, fallbacks). */ +export function resolveAdvance(cell: GlyphCell, font: Font): number { + const raw = font.glyph(cell.glyphName)?.advance ?? 0; + return displayAdvance(raw, cell.glyphName, cell.codepoint); +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts new file mode 100644 index 00000000..1fea2661 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { glyphCell as glyph, linebreak } from "./types"; +import { loadTestFont, makeLayout } from "./testUtils"; +import { Font } from "@/lib/model/Font"; + +describe("TextLayout", () => { + let font: Font; + + beforeEach(() => { + font = loadTestFont(); + }); + + // Levien invariant via the public class surface. + it("measure equals sum of xAdvance across all runs", () => { + const layout = makeLayout([glyph("A", 65), glyph("B", 66), glyph("C", 67)], font); + + const sum = layout.lines + .flatMap((l) => l.runs) + .flatMap((r) => r.glyphs) + .reduce((s, g) => s + g.xAdvance, 0); + + expect(layout.measure()).toBe(sum); + expect(layout.totalAdvance).toBe(sum); + expect(layout.totalAdvance).toBeGreaterThan(0); + }); + + // Empty input → no lines, zero advance. + it("empty cell buffer producesNoShapePositionerand zero advance", () => { + const layout = makeLayout([], font); + + expect(layout.lines).toEqual([]); + expect(layout.totalAdvance).toBe(0); + }); + + // Linebreak cell splits the buffer into two lines. + it("splits on linebreak cell into separate lines", () => { + const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + + expect(layout.lines).toHaveLength(2); + expect(layout.lines[1].y).toBeLessThan(layout.lines[0].y); + }); + + // Second-line baseline math: y = origin.y - lineHeight. + it("second-line baseline is one lineHeight below first", () => { + const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + const metrics = font.getMetrics(); + const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); + + expect(layout.lines[0].y).toBe(0); + expect(layout.lines[1].y).toBe(-lineHeight); + }); + + // hit-test → pointAt round trip. Pin side resolution: a point inside the + // left half of B's advance box hits cluster=1, side="left". + it("pointAt after hitTest recovers cluster's leading edge", () => { + const layout = makeLayout([glyph("A", 65), glyph("B", 66)], font); + const aAdvance = font.glyph("A")?.advance ?? 0; + const bAdvance = font.glyph("B")?.advance ?? 0; + const bLeftHalfX = aAdvance + bAdvance / 4; + + const hit = layout.hitTest({ x: bLeftHalfX, y: 0 }); + + expect(hit).toEqual({ lineIndex: 0, runIndex: 0, cluster: 1, side: "left" }); + expect(layout.pointAt(1)?.x).toBe(aAdvance); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts new file mode 100644 index 00000000..8db8ca12 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts @@ -0,0 +1,258 @@ +/** + * TextLayout — class facade over the segment → position → assemble pipeline. + * + * Coordinates: y is negative-down from origin; line N baseline is + * `origin.y - lineHeight * n`. Cluster is whole-buffer monotonic. + */ +import type { Bounds as BoundsType } from "@shift/geo"; +import { Caret } from "./Caret"; +import type { + CaretPosition, + Cell, + FontMetrics, + GlyphCell, + Hit, + Line, + ParagraphSlice, + Point2D, + PositionedRun, + SegmentedRun, +} from "./types"; +import type { Positioner } from "./Positioner"; +import { Font } from "@/lib/model/Font"; + +export interface TextLayoutParams { + cells: readonly Cell[]; + origin: Point2D; + font: Font; + positioner: Positioner; +} + +export class TextLayout { + readonly metrics: FontMetrics; + readonly origin: Point2D; + readonly lines: readonly Line[]; + readonly totalAdvance: number; + /** @knipclassignore — bbox union over positioned glyphs; populated when shapeHitTest lands */ + readonly bounds: BoundsType | null; + readonly bufferLength: number; + + constructor(params: TextLayoutParams) { + const { cells, origin, font, positioner } = params; + this.metrics = font.getMetrics(); + this.origin = origin; + this.bufferLength = cells.length; + + // splitParagraphs → segmentRuns → position → assemble + // Outer array = paragraphs (one Line each); inner array = runs in that paragraph. + const positionedParagraphs: PositionedRun[][] = splitParagraphs(cells).map((p) => + segmentRuns(p).map((run) => positioner.position(run, font)), + ); + + const { lines, totalAdvance, bounds } = assembleLayout( + positionedParagraphs, + origin, + this.metrics, + ); + this.lines = lines; + this.totalAdvance = totalAdvance; + this.bounds = bounds; + } + + /** + * Hit-test a canvas point against the layout's advance boxes. + * + * 1. Find the line whose vertical band [y+descent, y+ascent] contains p.y + * (with optional padding for edge tolerance). + * 2. Walk that line's runs/glyphs, accumulating x from `origin.x`. Return + * the glyph whose advance box [left, right) contains p.x. + * 3. Within the hit glyph, "left" if p.x is in the left half of the + * advance box, "right" otherwise. + * + * Returns null when no line / no glyph contains the point. + */ + hitTest(p: Point2D, padding: number = 0): Hit | null { + for (const [lineIndex, line] of this.lines.entries()) { + const top = line.y + line.ascent; + const bottom = line.y + line.descent; + if (p.y > top + padding || p.y < bottom - padding) continue; + + let cursor = this.origin.x; + for (const [runIndex, run] of line.runs.entries()) { + for (const g of run.glyphs) { + const left = cursor; + const right = cursor + g.xAdvance; + if (p.x >= left - padding && p.x < right + padding) { + const mid = left + g.xAdvance / 2; + return { + lineIndex, + runIndex, + cluster: g.cluster, + side: p.x < mid ? "left" : "right", + }; + } + cursor = right; + } + } + return null; + } + return null; + } + + /** @knipclassignore — precise glyph-shape hit-test, deferred to follow-up */ + shapeHitTest(_p: Point2D, _font: Font): Hit | null { + throw new Error("TextLayout.shapeHitTest not implemented"); + } + + /** + * Inverse of hitTest: leading-edge canvas point of a positioned glyph at + * `cluster`. Returns null if the cluster has no positioned glyph (e.g. + * cluster falls on a linebreak, or past the buffer end). + */ + pointAt(cluster: number): CaretPosition | null { + const lineHeight = this.metrics.ascender - this.metrics.descender + (this.metrics.lineGap ?? 0); + for (const line of this.lines) { + let cursor = this.origin.x; + for (const run of line.runs) { + for (const g of run.glyphs) { + if (g.cluster === cluster) { + return { x: cursor, y: line.y, lineHeight }; + } + cursor += g.xAdvance; + } + } + } + return null; + } + + /** @knipclassignore — convenience for caret construction at a cluster */ + caretAt(cluster: number): Caret { + return Caret.atCluster(this, cluster); + } + + /** @knipclassignore — convenience for caret construction at a canvas point */ + caretAtPoint(p: Point2D): Caret { + return Caret.atPoint(this, p); + } + + measure(): number { + return this.totalAdvance; + } +} + +/** + * Split the flat cell buffer into paragraphs on linebreak cells. + * + * Linebreaks are *separators*, not glyphs — they're excluded from any + * paragraph's `glyphs` array but they consume a cluster index. The next + * paragraph's `clusterStart` is `previous.clusterStart + previous.glyphs.length + 1` + * (the +1 is the linebreak itself). + * + * buffer: [A, B, \n, C, D] buffer: [A, \n, \n, B] + * index: 0 1 2 3 4 index: 0 1 2 3 + * + * output: [{[A,B], cs:0}, output: [{[A], cs:0}, + * {[C,D], cs:3}] {[], cs:2}, + * {[B], cs:3}] + * + * buffer: [\n, A] buffer: [A, \n] buffer: [] + * + * output: [{[], cs:0}, output: [{[A], cs:0}, output: [] + * {[A], cs:1}] {[], cs:2}] + */ +function splitParagraphs(cells: readonly Cell[]): ParagraphSlice[] { + const paragraphs: ParagraphSlice[] = []; + let glyphs: GlyphCell[] = []; + let clusterStart = 0; + + cells.forEach((cell, index) => { + if (cell.kind === "linebreak") { + paragraphs.push({ glyphs, clusterStart }); + glyphs = []; + clusterStart = index + 1; + return; + } + glyphs.push(cell); + }); + + if (glyphs.length > 0 || cells.length > 0) { + paragraphs.push({ glyphs, clusterStart }); + } + + return paragraphs; +} + +/** + * Segment a single paragraph into runs by direction / script / language. + * + * Phase 1: trivial — every paragraph becomes one LTR run, no BiDi or script + * detection. Returned as a `SegmentedRun[]` (single-element array) so the + * shape stays stable when Phase 3 introduces real BiDi. + * + * Phase 1 (today): + * paragraph: { glyphs: [A, B, C], cs: 0 } + * output: [{ glyphs: [A,B,C], direction: "ltr", clusterStart: 0 }] + * + * Phase 3 (future, with BiDi): + * paragraph: { glyphs: ["h","e","l","l","o"," ","ا","ل","ع","ر","ب","ي","ة"], cs: 0 } + * output: [ + * { glyphs: ["h","e","l","l","o"," "], direction: "ltr", cs: 0 }, + * { glyphs: ["ا","ل","ع","ر","ب","ي","ة"], direction: "rtl", cs: 6 }, + * ] + * + * Per-paragraph signature (not paragraphs[]) so paragraph structure is + * preserved through the pipeline — assembleLayout makes one Line per paragraph, + * each Line's `runs` field holds however many runs that paragraph produced. + */ +function segmentRuns(paragraph: ParagraphSlice): SegmentedRun[] { + return [ + { + glyphs: paragraph.glyphs, + direction: "ltr", + clusterStart: paragraph.clusterStart, + }, + ]; +} + +/** + * Stack positioned paragraphs into Lines and sum the total advance. + * + * One Line per paragraph. Line 0's baseline sits at `origin.y`; line N's at + * `origin.y - lineHeight * n` (negative-down, matching font convention where + * ascender > 0 and descender < 0). + * + * origin.y = 0, lineHeight = 1000: + * + * line 0 baseline y = 0 ────────── [A B] + * line 1 baseline y = -1000 ────────── [C D] + * line 2 baseline y = -2000 ────────── [] ← empty paragraph still gets a line + * + * `totalAdvance` is summed from `run.advance` (which Positioner already + * computed as the sum of its glyphs' xAdvance). + * + * `bounds` returns null for now; full bbox union over positioned glyphs is + * a follow-up. No current test requires it. + */ +function assembleLayout( + positionedParagraphs: PositionedRun[][], + origin: Point2D, + metrics: FontMetrics, +): { lines: Line[]; totalAdvance: number; bounds: BoundsType | null } { + const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); + + const lines: Line[] = positionedParagraphs.map((runs, i) => ({ + runs, + y: origin.y - lineHeight * i, + ascent: metrics.ascender, + descent: metrics.descender, + })); + + let totalAdvance = 0; + for (const line of lines) { + for (const run of line.runs) { + totalAdvance += run.advance; + } + } + + return { lines, totalAdvance, bounds: null }; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts new file mode 100644 index 00000000..c0a21934 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts @@ -0,0 +1,17 @@ +export { TextLayout } from "./TextLayout"; +export type { TextLayoutParams } from "./TextLayout"; +export { Caret } from "./Caret"; +export { Positioner } from "./Positioner"; +export { glyphCell, linebreak } from "./types"; +export type { + CaretPosition, + Cell, + Direction, + GlyphCell, + Hit, + Line, + LineBreak, + PositionedGlyph, + PositionedRun, + SegmentedRun, +} from "./types"; diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts new file mode 100644 index 00000000..71c5ceec --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts @@ -0,0 +1,33 @@ +/** + * Shared test fixtures for the layout module. + * + * Loads a real Font (MutatorSans) via the real Rust bridge — same pattern + * as `GlyphView.test.ts`. No fakes; tests assert against derived values + * read from the loaded font, so `expect(... .advance).toBe(font.glyph("A")?.advance)` + * not a hardcoded 600. + */ +import { Font } from "@/lib/model/Font"; +import { createBridge } from "@/testing/engine"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import { TextLayout } from "./TextLayout"; +import { Positioner } from "./Positioner"; +import type { Cell, GlyphCell, SegmentedRun } from "./types"; + +export function loadTestFont(): Font { + const font = new Font(createBridge()); + font.load(MUTATORSANS_DESIGNSPACE); + return font; +} + +export function ltrRun(glyphs: readonly GlyphCell[], clusterStart = 0): SegmentedRun { + return { glyphs, direction: "ltr", clusterStart }; +} + +export function makeLayout(cells: readonly Cell[], font: Font): TextLayout { + return new TextLayout({ + cells, + origin: { x: 0, y: 0 }, + font, + positioner: new Positioner(), + }); +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts new file mode 100644 index 00000000..b14d0d30 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts @@ -0,0 +1,85 @@ +import type { FontMetrics, Point2D } from "@shift/types"; +import type { Bounds } from "@shift/geo"; + +/** + * A single item in a text buffer. Either a glyph (typed character or picked + * variant) or a structural line break. Line breaks are NOT glyphs — they + * never get positioned; the layout splits on them into paragraphs. + */ +export type Cell = GlyphCell | LineBreak; + +export interface GlyphCell { + kind: "glyph"; + glyphName: string; + /** Source codepoint when typed via keyboard; null when picked from a glyph UI. */ + codepoint: number | null; +} + +export interface LineBreak { + kind: "linebreak"; +} + +/** Build a glyph cell. `codepoint` is null when the source isn't a typed character. */ +export function glyphCell(glyphName: string, codepoint: number | null = null): GlyphCell { + return { kind: "glyph", glyphName, codepoint }; +} + +/** Singleton linebreak cell — structural paragraph separator. */ +export const linebreak: LineBreak = { kind: "linebreak" }; + +export interface PositionedGlyph { + glyphName: string; + xAdvance: number; + yAdvance: number; + xOffset: number; + yOffset: number; + cluster: number; + bounds: Bounds | null; +} + +export type Direction = "ltr" | "rtl"; + +export interface SegmentedRun { + glyphs: readonly GlyphCell[]; + direction: Direction; + script?: string; + language?: string; + features?: Record; + clusterStart: number; +} + +export interface PositionedRun { + glyphs: PositionedGlyph[]; + direction: Direction; + script?: string; + language?: string; + features?: Record; + advance: number; +} + +export interface Line { + runs: PositionedRun[]; + y: number; + ascent: number; + descent: number; +} + +export interface Hit { + lineIndex: number; + runIndex: number; + cluster: number; + side: "left" | "right"; +} + +export interface CaretPosition { + x: number; + y: number; + lineHeight: number; +} + +export interface ParagraphSlice { + glyphs: readonly GlyphCell[]; + clusterStart: number; +} + +export type { FontMetrics, Point2D }; diff --git a/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts b/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts index c39a7d51..399aa903 100644 --- a/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts +++ b/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { + cellFromCodepoint, fallbackGlyphNameForUnicode, - glyphRefFromUnicode, resolveGlyphNameFromUnicode, } from "./unicode"; @@ -34,13 +34,13 @@ describe("resolveGlyphNameFromUnicode", () => { }); }); -describe("glyphRefFromUnicode", () => { - it("returns a full glyph ref", () => { - const ref = glyphRefFromUnicode(0x41, { +describe("cellFromCodepoint", () => { + it("returns a glyph cell with the resolved name and codepoint", () => { + const cell = cellFromCodepoint(0x41, { getExistingGlyphNameForUnicode: () => "A", getMappedGlyphName: () => null, }); - expect(ref).toEqual({ glyphName: "A", unicode: 0x41 }); + expect(cell).toEqual({ kind: "glyph", glyphName: "A", codepoint: 0x41 }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/utils/unicode.ts b/apps/desktop/src/renderer/src/lib/utils/unicode.ts index 62a2cb1a..45fe2687 100644 --- a/apps/desktop/src/renderer/src/lib/utils/unicode.ts +++ b/apps/desktop/src/renderer/src/lib/utils/unicode.ts @@ -1,4 +1,4 @@ -import type { GlyphRef } from "@/lib/tools/text/layout"; +import type { GlyphCell } from "@/lib/tools/text/layout"; /** * Convert a Unicode codepoint to its hex representation without prefix (e.g. for URLs). @@ -37,10 +37,11 @@ export function resolveGlyphNameFromUnicode(unicode: number, deps: GlyphNameReso return fallbackGlyphNameForUnicode(unicode); } -export function glyphRefFromUnicode(unicode: number, deps: GlyphNameResolverDeps): GlyphRef { +export function cellFromCodepoint(codepoint: number, deps: GlyphNameResolverDeps): GlyphCell { return { - glyphName: resolveGlyphNameFromUnicode(unicode, deps), - unicode, + kind: "glyph", + glyphName: resolveGlyphNameFromUnicode(codepoint, deps), + codepoint, }; } diff --git a/packages/validation/src/persistence.test.ts b/packages/validation/src/persistence.test.ts index b80cd6f5..ccf62c83 100644 --- a/packages/validation/src/persistence.test.ts +++ b/packages/validation/src/persistence.test.ts @@ -36,14 +36,15 @@ describe("persistence schemas", () => { payload: { runsByGlyph: { "65": { - glyphs: [ - { glyphName: "A", unicode: 65 }, - { glyphName: "B", unicode: 66 }, - ], - cursorPosition: 2, - originX: 100, - editingIndex: null, - editingGlyph: null, + buffer: { + cells: [ + { kind: "glyph", glyphName: "A", codepoint: 65 }, + { kind: "glyph", glyphName: "B", codepoint: 66 }, + ], + cursor: 2, + anchor: 2, + originX: 100, + }, }, }, }, @@ -61,11 +62,12 @@ describe("persistence schemas", () => { const result = TextRunModuleSchema.safeParse({ runsByGlyph: { "65": { - glyphs: [{ glyphName: "A", unicode: 65 }], - cursorPosition: "1", - originX: 0, - editingIndex: null, - editingGlyph: null, + buffer: { + cells: [{ kind: "glyph", glyphName: "A", codepoint: 65 }], + cursor: "1", + anchor: 0, + originX: 0, + }, }, }, }); diff --git a/packages/validation/src/persistence.ts b/packages/validation/src/persistence.ts index c5383d4e..0276f311 100644 --- a/packages/validation/src/persistence.ts +++ b/packages/validation/src/persistence.ts @@ -1,16 +1,26 @@ import { z } from "zod"; -export const GlyphRefSchema = z.object({ +export const GlyphCellSchema = z.object({ + kind: z.literal("glyph"), glyphName: z.string().min(1), - unicode: z.number().int().nonnegative().nullable(), + codepoint: z.number().int().nonnegative().nullable(), }); -export const PersistedTextRunSchema = z.object({ - glyphs: z.array(GlyphRefSchema), - cursorPosition: z.number().int().nonnegative(), +export const LineBreakSchema = z.object({ + kind: z.literal("linebreak"), +}); + +export const CellSchema = z.discriminatedUnion("kind", [GlyphCellSchema, LineBreakSchema]); + +export const TextBufferSnapshotSchema = z.object({ + cells: z.array(CellSchema), + cursor: z.number().int().nonnegative(), + anchor: z.number().int().nonnegative(), originX: z.number().finite(), - editingIndex: z.number().int().nonnegative().nullable(), - editingGlyph: GlyphRefSchema.nullable(), +}); + +export const PersistedTextRunSchema = z.object({ + buffer: TextBufferSnapshotSchema, }); export const TextRunModuleSchema = z.object({ From 819a80c326882e18c1a1ff29a17b7674fc96a76f Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Apr 2026 20:23:38 +0100 Subject: [PATCH 02/13] chore: add commit skill --- .claude/skills/commit/SKILL.md | 144 +++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .claude/skills/commit/SKILL.md diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 00000000..0392951d --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,144 @@ +--- +name: commit +description: Canonical rules for writing git commits in the Shift codebase. Use whenever the user asks to commit, stage and commit, or "make a commit" — and whenever you're about to draft a commit message. Enforces conventional prefixes, concise subjects, and splitting unrelated changes into separate commits. +--- + +# /commit — How commits are written in this codebase + +The goal: a `git log --oneline` that reads like a changelog. Each line tells you what changed and why in under ~70 characters, with a prefix that lets you grep for features vs fixes vs refactors. + +## The rule + +**Every commit subject is `: `. Every commit is one logical change.** + +If you can't describe the change in one short clause, it's probably two commits. + +## Step 0 — ask the user before drafting + +Before writing any commit message, **ask the user to describe the changes in their own words**. They know the why; you only see the diff. A one-line description from the user beats five minutes of you guessing from code. + +Skip this step only if: + +- The user already told you the goal in this conversation +- The change is trivially self-describing (e.g. a single typo fix, a single test rename) + +When asking, keep it tight: + +> "Before I commit — give me a one-line description of what these changes do and why. If multiple things are in flight, list them separately so I can split commits." + +## Allowed types + +| Type | Use for | +| ---------- | ----------------------------------------------------------------------------------------------------------- | +| `feat` | New user-visible functionality | +| `fix` | Bug fix | +| `refactor` | Code change with no behavior change | +| `test` | Adding or rewriting tests, no production code changes | +| `docs` | Documentation only (DOCS.md, README, comments) | +| `perf` | Performance improvement, no behavior change | +| `style` | Formatting only (prettier, oxlint --fix). **Not** "look-and-feel UI tweaks\*\* — those are `feat` or `fix`. | +| `build` | Build system, native compile, package scripts | +| `chore` | Tooling, config, dependencies, repo housekeeping | +| `ci` | CI workflows | + +If a change touches multiple types, split it. A `feat` that also fixes an unrelated bug is two commits. + +## Subject line rules + +- **≤ 72 characters total**, including the `: ` prefix. Aim for 50. +- **Lowercase after the colon.** `feat: add ...`, not `feat: Add ...`. +- **Imperative mood.** "add reactive glyph signal", not "added" / "adds". +- **No trailing period.** +- **No file paths or symbol names** unless they're load-bearing for understanding (`fix: GlyphView interpolation on composite scrub` is fine; `fix: update GlyphView.ts line 142` is not). +- **No emoji.** No "🤖 Generated with Claude Code" footer unless the user explicitly asks. + +## Body rules + +Most commits don't need a body. Add one when: + +- The "why" isn't obvious from the subject (constraint, prior incident, non-local consequence). +- You need to call out a follow-up, a known limitation, or a behavioral subtlety. + +When you write a body: + +- Blank line after the subject. +- Wrap at ~72 chars. +- Bullets are fine. Walls of prose are not. +- Don't restate the diff. Explain what the diff doesn't say. + +## Splitting commits + +Before staging, look at what's modified and ask: **does this collapse to one logical change, or several?** + +Strong signals to split: + +- Unrelated subsystems touched (e.g. text layout AND clipboard). +- A file rename / directory reorg mixed with logic edits in those files. → reorg first, edits second. +- Test-only changes that are independent of the feature (e.g. migrating an old test to TestEditor while also adding a new feature). → split. +- Generated code regenerated alongside hand-edits. → split (`chore: regenerate types` separate from the `feat`). +- A formatting sweep landed on top of real changes. → `style:` separate. +- Docs refresh alongside code. → fine to bundle if the docs describe the code in the same commit; split if the docs are a broader refresh. + +Signals to **keep together**: + +- Production code + the tests that cover it. +- A type change + the call sites it forces. +- A rename + the imports that follow from it. + +When in doubt: propose the split to the user and let them confirm before you stage anything. A wrong split is annoying to unwind. + +### How to propose a split + +After the user describes the changes, send a short plan like: + +> Proposed commits: +> +> 1. `refactor: move text/* under lib/text` — pure file moves, no logic changes +> 2. `feat: ` — Editor.ts, NativeBridge.ts, App.tsx +> 3. `test: cover ` — new Editor.test.ts and updated suites +> 4. `docs: refresh text/ DOCS.md` +> +> Want me to stage and commit in that order? + +Wait for confirmation before running `git add` / `git commit`. + +## Examples from this repo + +**Good** (keep doing these): + +- `feat: text-system rewrite — bottom-up rebuild around Cell + TextRun` +- `fix: canvas interpolates composite components on slider scrub` +- `test: GlyphView interpolation against MutatorSans` +- `refactor: sweep leaky naming suffixes` + +**Bad** (don't do these): + +- `add variation cache to the editor, fix bug where edit sessions did not have current variation when opening a new glyph for editing` + → no prefix, two unrelated changes, runs to ~120 chars. Should have been: + - `feat: cache variation state on Editor` + - `fix: hydrate edit session with current variation on glyph open` +- `style fixes` + → vague. What style? Where? Use `style: prettier sweep across renderer/` or just merge into the parent commit. +- `component restructure` + → no prefix, says nothing. `refactor: split into + `. +- `more home sidebar design` + → "more" is a smell that says "I didn't bother to describe this". Squash into the parent or rename to `feat: home sidebar — `. + +## Process + +1. **Ask the user for a one-line description** (Step 0) unless already covered. +2. Run `git status` and `git diff` (and `git diff --cached` if anything is already staged) in parallel. Read enough of the diff to know what changed — don't trust the file list alone. +3. Look at recent `git log --oneline -10` to match style and prefix conventions. +4. **Decide: one commit or several?** Apply the splitting rules above. If splitting, propose the plan to the user and wait for confirmation. +5. Stage explicitly with `git add ` — never `git add -A` or `git add .`. Per-commit staging keeps splits clean. +6. Commit each unit with `git commit -m "$(cat <<'EOF' ... EOF)"` (heredoc, so multi-line bodies survive shell quoting). +7. Run `git status` after each commit to confirm what's left. + +## Hard rules + +- **Never** `git commit` until the user has explicitly asked for a commit, or has confirmed the proposed split. +- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`) unless the user explicitly asks. If a pre-commit hook fails, fix the issue and create a NEW commit — do not `--amend`. +- **Never** commit files that look like secrets (`.env`, `credentials.json`, anything with API keys in the diff). Flag them and ask. +- **Never** stage with `-A` / `.` — pick paths. +- **Never** push as part of a commit task unless the user asks. +- **Never** add Claude Code attribution / co-author trailers unless the user asks. From df3b350d61b80f0b6b5b742ea17dcf486f3ca926 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Apr 2026 20:25:18 +0100 Subject: [PATCH 03/13] chore: reorder type exports in domain.ts --- packages/types/src/domain.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index 646839dd..d3cd270a 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -34,9 +34,6 @@ export type Point = Readonly; /** A named glyph attachment anchor. Immutable view of {@link AnchorSnapshot}. */ export type Anchor = Readonly; -/** A render-only point used in flattened composite contours. */ -export type RenderPoint = Readonly; - /** Decomposed affine transform (translate, rotate, scale, skew). Immutable view. */ export type DecomposedTransform = Readonly; @@ -50,11 +47,6 @@ export type Contour = Readonly> & { readonly points: readonly Point[]; }; -/** A render-only contour produced by flattening component references. */ -export type RenderContour = Readonly> & { - readonly points: readonly RenderPoint[]; -}; - /** * A full glyph with all contours and their points frozen. * Contours are ordered by creation time (newest last). The order is @@ -67,6 +59,14 @@ export type Glyph = Readonly; + +/** A render-only contour produced by flattening component references. */ +export type RenderContour = Readonly> & { + readonly points: readonly RenderPoint[]; +}; + /** A single component of a composite glyph. */ export type CompositeComponent = { readonly componentGlyphName: string; From c7f1bbd5a574ae4e29b98c48afa8f5450589f747 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Apr 2026 20:28:28 +0100 Subject: [PATCH 04/13] feat: lib/text + glyph handle + text run rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles four threads that share consumers (Editor.ts, tools/text/Text.ts) and cannot be split without breaking typecheck on intermediate commits: - Promote text/* out of tools/ into lib/text; finalize Cell + TextRun with stable cell ids carried through the persistence schema. - Replace startEditSession(glyphName, unicode?) with a GlyphHandle object on NativeBridge; ripple through every test caller. - Render text runs in the editor scene via rendering/Text.ts; rework Editor focused-glyph reactivity around GlyphAnchor + Positioner. - Renames: graphics ReglHandleContext → Gpu, tools text TypingBehaviour → TypingBehavior, tool class Text → TextTool. --- apps/desktop/src/renderer/src/app/App.tsx | 6 +- .../renderer/src/bridge/NativeBridge.test.ts | 12 +- .../src/renderer/src/bridge/NativeBridge.ts | 9 +- .../src/components/editor/BooleanOps.tsx | 6 +- .../src/components/editor/EditorView.tsx | 11 +- .../{editor => text}/HiddenTextInput.tsx | 7 +- .../renderer/src/context/CanvasContext.tsx | 4 +- .../clipboard/ClipboardCommands.test.ts | 6 +- .../lib/commands/core/CommandHistory.test.ts | 8 +- .../primitives/BezierCommands.test.ts | 2 +- .../commands/primitives/PointCommands.test.ts | 2 +- .../SetNodePositionsCommand.test.ts | 2 +- .../primitives/SidebearingCommands.test.ts | 2 +- .../transform/TransformCommands.test.ts | 2 +- .../src/renderer/src/lib/editor/Editor.ts | 226 ++++++++++-------- .../src/renderer/src/lib/editor/docs/DOCS.md | 6 +- .../lib/editor/managers/SnapManager.test.ts | 2 +- .../src/lib/editor/rendering/Handles.ts | 6 +- .../renderer/src/lib/editor/rendering/Text.ts | 104 ++++++++ .../src/lib/editor/rendering/Viewport.ts | 8 +- .../renderer/src/lib/editor/variation.test.ts | 4 +- .../backends/{ReglHandleContext.ts => Gpu.ts} | 2 +- .../renderer/src/lib/model/GlyphView.test.ts | 2 +- .../src/renderer/src/lib/model/GlyphView.ts | 37 ++- .../src/renderer/src/lib/model/glyph.test.ts | 2 +- .../lib/{tools => }/text/TextBuffer.test.ts | 45 +++- .../src/lib/{tools => }/text/TextBuffer.ts | 16 +- .../{tools => }/text/TextInteraction.test.ts | 15 +- .../lib/{tools => }/text/TextInteraction.ts | 0 .../src/lib/{tools => }/text/TextRun.ts | 179 ++++++++++++-- .../src/lib/{tools => }/text/TextRuns.ts | 56 ++++- .../src/renderer/src/lib/text/docs/DOCS.md | 74 ++++++ .../lib/{tools => }/text/layout/Caret.test.ts | 39 ++- .../src/lib/{tools => }/text/layout/Caret.ts | 82 ++++++- .../text/layout/Positioner.test.ts | 5 +- .../src/lib/text/layout/Positioner.ts | 91 +++++++ .../text/layout/TextLayout.test.ts | 34 ++- .../lib/{tools => }/text/layout/TextLayout.ts | 111 +++++++-- .../src/lib/{tools => }/text/layout/index.ts | 5 +- .../lib/{tools => }/text/layout/testUtils.ts | 0 .../src/lib/{tools => }/text/layout/types.ts | 42 +++- .../core/StateDiagram.compliance.test.ts | 2 +- .../lib/tools/select/behaviors/TextRunEdit.ts | 20 +- .../tools/select/behaviors/TextRunHover.ts | 22 +- .../src/renderer/src/lib/tools/text/Text.ts | 31 +-- .../{TypingBehaviour.ts => TypingBehavior.ts} | 7 +- .../src/lib/tools/text/layout/Positioner.ts | 41 ---- .../src/renderer/src/lib/tools/tools.ts | 2 +- .../renderer/src/lib/utils/unicode.test.ts | 3 +- .../src/renderer/src/lib/utils/unicode.ts | 9 +- .../src/renderer/src/perf/drawing.bench.ts | 2 +- .../src/renderer/src/testing/TestEditor.ts | 8 +- .../src/renderer/src/testing/pointMark.ts | 2 +- .../src/renderer/src/views/Landing.tsx | 6 +- .../src/shared/bridge/FontEngineAPI.ts | 4 +- crates/shift-node/docs/DOCS.md | 16 +- crates/shift-node/index.d.ts | 4 +- crates/shift-node/src/font_engine.rs | 18 +- packages/validation/src/persistence.test.ts | 6 +- packages/validation/src/persistence.ts | 2 + 60 files changed, 1113 insertions(+), 364 deletions(-) rename apps/desktop/src/renderer/src/components/{editor => text}/HiddenTextInput.tsx (96%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts rename apps/desktop/src/renderer/src/lib/graphics/backends/{ReglHandleContext.ts => Gpu.ts} (99%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextBuffer.test.ts (76%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextBuffer.ts (94%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextInteraction.test.ts (88%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextInteraction.ts (100%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextRun.ts (57%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/TextRuns.ts (60%) create mode 100644 apps/desktop/src/renderer/src/lib/text/docs/DOCS.md rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/Caret.test.ts (53%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/Caret.ts (50%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/Positioner.test.ts (92%) create mode 100644 apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/TextLayout.test.ts (64%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/TextLayout.ts (74%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/index.ts (75%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/testUtils.ts (100%) rename apps/desktop/src/renderer/src/lib/{tools => }/text/layout/types.ts (62%) rename apps/desktop/src/renderer/src/lib/tools/text/behaviors/{TypingBehaviour.ts => TypingBehavior.ts} (63%) delete mode 100644 apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index c707e404..1c1d72db 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -60,9 +60,9 @@ export const App = () => { const unicode = parseEditorUnicodeFromHash(window.location.hash); if (unicode !== null) { const glyphName = editor.font.glyphName(unicode); - editor.setMainGlyphUnicode(unicode); - editor.open(glyphName); - editor.setDrawOffsetForGlyph({ x: 0, y: 0 }, glyphName, unicode); + const handle = { glyphName, unicode }; + editor.setGlyphHandle(handle); + editor.openGlyph(handle); } } else { navigateToHome(); diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts index c5ecee6a..71502bc1 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts @@ -15,7 +15,7 @@ describe("NativeBridge session lifecycle", () => { }); it("startEditSession opens a session and populates $glyph", () => { - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); expect(bridge.hasSession()).toBe(true); expect(bridge.$glyph.peek()).not.toBe(null); @@ -23,7 +23,7 @@ describe("NativeBridge session lifecycle", () => { }); it("endEditSession clears the session and nulls $glyph", () => { - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.endEditSession(); expect(bridge.hasSession()).toBe(false); @@ -31,20 +31,20 @@ describe("NativeBridge session lifecycle", () => { }); it("starting the same glyph again is a no-op — $glyph reference is preserved", () => { - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); const first = bridge.$glyph.peek(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); const second = bridge.$glyph.peek(); expect(second).toBe(first); }); it("switching to a different glyph replaces the Glyph instance", () => { - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); const first = bridge.$glyph.peek(); - bridge.startEditSession("B"); + bridge.startEditSession({ glyphName: "B" }); const second = bridge.$glyph.peek(); expect(bridge.getEditingGlyphName()).toBe("B"); diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index 822f5e3f..9b089f83 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -23,7 +23,7 @@ import { NoEditSessionError, NativeOperationError } from "./errors"; import { constrainDrag } from "@shift/rules"; import { ValidateSnapshot } from "@shift/validation"; import { Glyphs } from "@shift/font"; -import type { FontEngineAPI } from "@shared/bridge/FontEngineAPI"; +import type { FontEngineAPI, GlyphHandle } from "@shared/bridge/FontEngineAPI"; import type { CompositeComponents } from "@shared/bridge/FontEngineAPI"; import type { CommandResult, PasteResult, PointEdit } from "@/types/engine"; import { ContourContent } from "@/lib/clipboard"; @@ -65,14 +65,13 @@ export class NativeBridge { return this.#raw.hasEditSession(); } - startEditSession(glyphName: string, unicode?: number): void { + startEditSession(handle: GlyphHandle): void { if (this.hasSession()) { const currentName = this.getEditingGlyphName(); - if (currentName === glyphName) return; + if (currentName === handle.glyphName) return; this.endEditSession(); } - const ref = unicode !== undefined ? { glyphName, unicode } : { glyphName }; - this.#raw.startEditSession(ref); + this.#raw.startEditSession(handle); this.#$glyph.set(this.hasSession() ? new Glyph(this) : null); } diff --git a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx index 049f633e..f791d244 100644 --- a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx +++ b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx @@ -19,19 +19,19 @@ export const BooleanOps = () => { { - editor.applyBooleanOp(contourIdA, contourIdB, "union"); + editor.boolean(contourIdA, contourIdB, "union"); }} /> { - editor.applyBooleanOp(contourIdA, contourIdB, "intersect"); + editor.boolean(contourIdA, contourIdB, "intersect"); }} /> { - editor.applyBooleanOp(contourIdA, contourIdB, "subtract"); + editor.boolean(contourIdA, contourIdB, "subtract"); }} /> diff --git a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx index 6084843e..1629cdc9 100644 --- a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx +++ b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx @@ -8,7 +8,7 @@ import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; import { StaticScene } from "./StaticScene"; import { DebugPanel } from "../debug/DebugPanel"; -import { HiddenTextInput } from "./HiddenTextInput"; +import { TextInput } from "../text/HiddenTextInput"; import { Vec2 } from "@shift/geo"; interface EditorViewProps { @@ -35,10 +35,9 @@ export const EditorView: FC = ({ glyphId }) => { const glyphName = editor.font.glyphName(unicode); const initEditor = () => { - editor.setMainGlyphUnicode(unicode); - editor.open(glyphName); - - editor.setDrawOffsetForGlyph({ x: 0, y: 0 }, glyphName, unicode); + const handle = { glyphName, unicode }; + editor.setGlyphHandle(handle); + editor.openGlyph(handle); // Update viewport with actual font metrics (UPM, descender, guides) editor.updateMetricsFromFont(); @@ -107,7 +106,7 @@ export const EditorView: FC = ({ glyphId }) => { - + {debug?.debugPanelOpen && } ); diff --git a/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx b/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx similarity index 96% rename from apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx rename to apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx index dce8550c..9fc4d925 100644 --- a/apps/desktop/src/renderer/src/components/editor/HiddenTextInput.tsx +++ b/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx @@ -8,8 +8,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getEditor } from "@/store/store"; import { effect } from "@/lib/reactive/signal"; +import { linebreakCell } from "@/lib/text/layout"; -export function HiddenTextInput() { +export function TextInput() { const editor = getEditor(); const ref = useRef(null); const [isTextTool, setIsTextTool] = useState(false); @@ -23,7 +24,7 @@ export function HiddenTextInput() { const textareaRef = useCallback( (node: HTMLTextAreaElement | null) => { - (ref as React.MutableRefObject).current = node; + ref.current = node; if (!node) return; node.focus(); @@ -69,7 +70,7 @@ export function HiddenTextInput() { return; case "Enter": - run.insert({ kind: "linebreak" }); + run.insert(linebreakCell()); e.preventDefault(); return; diff --git a/apps/desktop/src/renderer/src/context/CanvasContext.tsx b/apps/desktop/src/renderer/src/context/CanvasContext.tsx index af93fb98..67cbfcf7 100644 --- a/apps/desktop/src/renderer/src/context/CanvasContext.tsx +++ b/apps/desktop/src/renderer/src/context/CanvasContext.tsx @@ -1,6 +1,6 @@ import { createContext, useEffect, useRef } from "react"; -import { ReglHandleContext } from "@/lib/graphics/backends/ReglHandleContext"; +import { Gpu } from "@/lib/graphics/backends/Gpu"; import { getEditor } from "@/store/store"; import { CanvasRef } from "@/types/graphics"; @@ -70,7 +70,7 @@ export const CanvasContextProvider = ({ children }: { children: React.ReactNode sceneCanvas: HTMLCanvasElement; backgroundCanvas: HTMLCanvasElement; }) => { - const gpuHandleContext = new ReglHandleContext(); + const gpuHandleContext = new Gpu(); const bgCtx = scaledContext(backgroundCanvas).ctx; const sceneCtx = scaledContext(sceneCanvas).ctx; diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts index a4179737..cc960b8e 100644 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts @@ -43,7 +43,7 @@ describe("CutCommand", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); @@ -150,7 +150,7 @@ describe("PasteCommand", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); @@ -236,7 +236,7 @@ describe("Cut + Paste integration", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts index 942269ea..dcf4b789 100644 --- a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts @@ -35,7 +35,7 @@ describe("CommandHistory", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); @@ -180,7 +180,7 @@ describe("batching", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); @@ -363,7 +363,7 @@ describe("onDirty callback", () => { onDirtyCalled++; }, }); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); @@ -430,7 +430,7 @@ describe("Command integration with history", () => { beforeEach(() => { bridge = createBridge(); history = new CommandHistory(bridge.$glyph); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts index ceb0b550..8cf1e83f 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts @@ -15,7 +15,7 @@ function ctx(): CommandContext { beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); }); describe("CloseContourCommand", () => { diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts index a946d924..51e3bf74 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts @@ -12,7 +12,7 @@ function ctx(): CommandContext { beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); }); describe("AddPointCommand", () => { diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts index c7ef40e9..19d37599 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts @@ -29,7 +29,7 @@ function ctx(): CommandContext { beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); bridge.addContour(); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts index 20e7a0ac..a4e29d77 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts @@ -16,7 +16,7 @@ function ctx(): CommandContext { beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); }); describe("SetXAdvanceCommand", () => { diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts index 10fa103c..fd243031 100644 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts @@ -12,7 +12,7 @@ function ctx(): CommandContext { beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); }); describe("RotatePointsCommand", () => { diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index f1b0c131..d90bef54 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -1,5 +1,5 @@ import type { HandleState } from "@/types/graphics"; -import { ReglHandleContext } from "@/lib/graphics/backends/ReglHandleContext"; +import { Gpu } from "@/lib/graphics/backends/Gpu"; import type { CursorType, SnapPreferences, ToolRegistryItem } from "@/types/editor"; import type { Point2D, @@ -11,6 +11,7 @@ import type { Point, AxisLocation, GlyphVariationData, + CompositeGlyph, } from "@shift/types"; import type { Glyph } from "@/lib/model/Glyph"; import { blockToPath2D } from "@/lib/model/GlyphView"; @@ -29,7 +30,7 @@ import type { BoundingBoxHitResult } from "@/types/boundingBox"; import type { Coordinates } from "@/types/coordinates"; import { ViewportManager } from "./managers"; -import { displayAdvance, isNonSpacingGlyph } from "@/lib/utils/unicode"; +import { displayAdvance } from "@/lib/utils/unicode"; import { NativeBridge } from "@/bridge"; import { CommandHistory, @@ -54,6 +55,7 @@ import { type DistributeType, } from "../transform"; import { + batch, computed, effect, signal, @@ -71,6 +73,7 @@ import { HoverManager, EdgePanManager } from "./managers"; import { Viewport, type ViewportTransform } from "./rendering/Viewport"; import type { Canvas } from "./rendering/Canvas"; import { Handles } from "./rendering/Handles"; +import { Text as TextRunDrawer } from "./rendering/Text"; import { Guides, BoundingBox, @@ -83,6 +86,7 @@ import { import { SCREEN_HIT_RADIUS } from "./rendering/constants"; import { getVisibleSceneBounds } from "./rendering/visibleSceneBounds"; import type { FocusZone } from "@/types/focus"; +import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; import type { DebugOverlays } from "@shared/ipc/types"; import type { TemporaryToolOptions } from "@/types/editor"; import { Selection } from "@/types/selection"; @@ -95,9 +99,10 @@ import type { SnapIndicator, } from "./snapping/types"; import { SnapManager } from "./managers/SnapManager"; -import { TextRuns } from "@/lib/tools/text/TextRuns"; -import type { TextRun } from "@/lib/tools/text/TextRun"; -import { Positioner } from "@/lib/tools/text/layout"; +import { TextRuns } from "@/lib/text/TextRuns"; +import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; +import { glyphCell, Positioner } from "@/lib/text/layout"; +import type { GlyphAnchor } from "@/lib/text/layout"; import { SnapPreferencesSchema, TextRunModuleSchema } from "@shift/validation"; import type { TextRunModule } from "@/persistence/types"; @@ -115,7 +120,6 @@ const defaultAppSettings: AppSettings = { pointRadiusPx: 8, }, }; -import type { CompositeGlyph } from "@shift/types"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; import { EventEmitter } from "./lifecycle"; @@ -129,6 +133,11 @@ interface EditorOptions { clipboard: SystemClipboard; } +interface GlyphPlacement { + focused: FocusedGlyph; + drawOffset: Point2D; +} + /** * Central orchestrator for the glyph editing surface. * @@ -170,6 +179,7 @@ export class Editor { #debugOverlaysIndicator = new DebugOverlaysIndicator(); #anchors = new Anchors(); #handles = new Handles(); + #textRunRenderer = new TextRunDrawer(); #toolManager: ToolManager; #toolMetadata: Map< @@ -199,13 +209,16 @@ export class Editor { #events: EventEmitter; #stateRegistry: StateRegistry; #textRuns: TextRuns; - #mainGlyphUnicode: number | null = null; + #mainGlyph: GlyphHandle | null = null; #$glyphFinderOpen: WritableSignal; #zone: FocusZone = "canvas"; #marqueePreviewPointIds: WritableSignal | null>; - #drawOffset: WritableSignal; + #$glyphAnchor: WritableSignal; + #$focusedGlyph: ComputedSignal; + #$glyphPlacement: ComputedSignal; + #$drawOffset: ComputedSignal; #cursor: WritableSignal; #currentModifiers: WritableSignal; #isHoveringNode: ComputedSignal; @@ -340,7 +353,20 @@ export class Editor { this.#textRuns.clearAll(); }); - this.#drawOffset = signal({ x: 0, y: 0 }); + this.#$glyphAnchor = signal(null); + this.#$focusedGlyph = computed(() => { + const anchor = this.#$glyphAnchor.value; + if (!anchor) return null; + return this.#textRuns.resolveAnchor(anchor); + }); + this.#$glyphPlacement = computed(() => { + const focused = this.#$focusedGlyph.value; + if (!focused) return null; + return { focused, drawOffset: focused.editOrigin }; + }); + this.#$drawOffset = computed( + () => this.#$glyphPlacement.value?.drawOffset ?? { x: 0, y: 0 }, + ); this.#staticEffect = effect(() => { const glyph = this.#$glyph.value; @@ -354,7 +380,8 @@ export class Editor { // The redraw path then walks font.glyph(name).componentContours() to // pick up freshly-interpolated component shapes. this.font.$variationLocation.value; - this.#drawOffset.value; + this.#$drawOffset.value; + this.#$focusedGlyph.value; this.$activeToolState.value; this.selection.pointIds; this.selection.anchorIds; @@ -368,8 +395,13 @@ export class Editor { this.#hover.hoveredBoundingBoxHandle.value; this.#debugOverlays.value; this.#gpuHandlesEnabled.value; - this.#textRuns.$active.value; - this.#textRuns.active.buffer.$cells.value; + const activeRun = this.#textRuns.$active.value; + activeRun.buffer.$cells.value; + activeRun.buffer.$cursor.value; + activeRun.buffer.$anchor.value; + activeRun.buffer.$originX.value; + activeRun.interaction.$editing.value; + activeRun.interaction.$hoveredIndex.value; this.#renderer.requestSceneRedraw(); this.#renderer.requestBackgroundRedraw(); }); @@ -380,7 +412,8 @@ export class Editor { glyph.contours; glyph.anchors; } - this.#drawOffset.value; + this.#$drawOffset.value; + this.#$focusedGlyph.value; this.selection.segmentIds; this.#hover.hoveredPointId.value; this.#hover.hoveredAnchorId.value; @@ -557,6 +590,12 @@ export class Editor { this.#toolManager.renderScene(canvas); + // Text run draws regardless of active glyph state — it's the run content + // (typed glyphs, selection, caret) the user sees while in the Text tool + // or hovering text-run cells from Select. World-space → drawOffset-local + // compensation lives inside the renderer. + this.#textRunRenderer.draw(canvas, this.textRun, this.font, this.drawOffset, this.focusedGlyph); + if (!previewMode && handlesVisible && glyph && this.shouldRenderGlyph()) { const viewport = this.getViewportTransform(); const drawOffset = this.drawOffset; @@ -873,12 +912,12 @@ export class Editor { this.#renderer.setOverlayContext(ctx); } - public setGpuHandleContext(context: ReglHandleContext) { + public setGpuHandleContext(context: Gpu) { this.#renderer.setGpuHandleContext(context); this.#handles.setGpu(context); } - public get gpuHandleContext(): ReglHandleContext | null { + public get gpuHandleContext(): Gpu | null { return this.#renderer.gpuHandleContext; } @@ -891,16 +930,59 @@ export class Editor { * active glyph immediately, so the canvas matches what the user clicked. * Subsequent slider scrubs go through `applyVariation`. */ - public open(glyphName: string): Glyph | null { + public open(handle: GlyphHandle): Glyph | null { const currentGlyphName = this.#bridge.getEditingGlyphName(); - if (currentGlyphName === glyphName) return this.#$glyph.peek(); + if (currentGlyphName === handle.glyphName) return this.#$glyph.peek(); - this.#bridge.startEditSession(glyphName); - this.#activeVariationData = this.font.getGlyphVariationData(glyphName); + this.#bridge.startEditSession(handle); + this.#activeVariationData = this.font.getGlyphVariationData(handle.glyphName); this.#applyCurrentVariationToActive(); return this.#$glyph.peek(); } + public openGlyph(handle: GlyphHandle): Glyph | null { + const anchor = this.#textRuns.editorRun().setSingleGlyph(handle); + this.setGlyphFocus(anchor); + return this.#$glyph.peek(); + } + + /** + * Focus a glyph cell and derive editor placement from current layout. + * + * GlyphAnchor { runId, cellId } + * │ + * ▼ + * TextRuns.resolveAnchor(anchor) + * │ + * ▼ + * native edit session + drawOffset = focused.editOrigin + */ + public setGlyphFocus(anchor: GlyphAnchor): void { + const focused = this.#textRuns.resolveAnchor(anchor); + if (!focused) { + this.clearGlyphFocus(); + return; + } + + batch(() => { + this.#$glyphAnchor.set(anchor); + this.open(focused.glyph); + this.setPreviewMode(false); + }); + } + + public clearGlyphFocus(): void { + this.#$glyphAnchor.set(null); + } + + public get focusedGlyph(): FocusedGlyph | null { + return this.#$focusedGlyph.value; + } + + public get glyphPlacement(): GlyphPlacement | null { + return this.#$glyphPlacement.value; + } + /** Ends the current editing session. */ public close(): void { this.#bridge.endEditSession(); @@ -946,13 +1028,17 @@ export class Editor { /** Resolve a unicode codepoint to a glyph cell and insert into the active text run. */ public insertTextCodepoint(codepoint: number): void { const glyphName = this.font.glyphName(codepoint); - this.textRun.insert({ kind: "glyph", glyphName, codepoint }); + this.textRun.insert(glyphCell(glyphName, codepoint)); } /** @knipclassignore Indirectly consumed through Viewport. */ public shouldRenderGlyph(): boolean { - const editing = this.#textRuns.active.interaction.editing; - return editing !== null; + const run = this.#textRuns.active; + // No active text-run activity → render the glyph normally (initial state, + // grid → canvas open, etc). + if (run.buffer.cells.length === 0 && !run.cursorVisible) return true; + // Active run → only render the glyph when focus belongs to a text cell in this run. + return this.#$focusedGlyph.value?.anchor.runId === run.id; } public getGlyphCompositeComponents(glyphName: string): CompositeGlyph | null { @@ -1014,14 +1100,13 @@ export class Editor { return this.#bridge.getEditingGlyphName(); } - public setMainGlyphUnicode(unicode: number | null): void { - this.#mainGlyphUnicode = unicode; - const glyphName = unicode === null ? null : this.font.glyphName(unicode); - this.#textRuns.switchTo(glyphName); + public setGlyphHandle(handle: GlyphHandle | null): void { + this.#mainGlyph = handle; + this.#textRuns.switchTo(handle?.glyphName ?? null); } - public getMainGlyphUnicode(): number | null { - return this.#mainGlyphUnicode; + public getGlyphHandle(): GlyphHandle | null { + return this.#mainGlyph; } public get commandHistory(): CommandHistory { @@ -1173,12 +1258,12 @@ export class Editor { } public sceneToGlyphLocal(point: Point2D): Point2D { - const offset = this.#drawOffset.value; + const offset = this.drawOffset; return { x: point.x - offset.x, y: point.y - offset.y }; } public glyphLocalToScene(point: Point2D): Point2D { - const offset = this.#drawOffset.value; + const offset = this.drawOffset; return { x: point.x + offset.x, y: point.y + offset.y }; } @@ -1279,10 +1364,9 @@ export class Editor { } this.font.load(filePath); this.#events.emit("fontLoaded", { font: this.font }); - this.setMainGlyphUnicode(65); - const name = this.font.glyphName(65); - this.open(name); - this.setDrawOffsetForGlyph({ x: 0, y: 0 }, name, 65); + const initial = { glyphName: this.font.glyphName(65), unicode: 65 }; + this.setGlyphHandle(initial); + this.openGlyph(initial); } public async saveFont(filePath: string): Promise { @@ -1476,7 +1560,7 @@ export class Editor { this.#commandHistory.execute(new UpgradeLineToCubicCommand(segment.raw as LineSegment)); } - public applyBooleanOp( + public boolean( contourIdA: ContourId, contourIdB: ContourId, operation: "union" | "subtract" | "intersect" | "difference", @@ -1729,25 +1813,12 @@ export class Editor { } public get drawOffset(): Point2D { - return this.#drawOffset.value; + return this.#$drawOffset.value; } /** @knipclassignore */ public get $drawOffset(): Signal { - return this.#drawOffset; - } - - public setDrawOffsetForGlyph( - offset: Point2D, - glyphName: string | null, - unicode: number | null = null, - ): void { - this.#drawOffset.set(this.#resolveEditorPlacementOffset(offset, glyphName, unicode)); - } - - /** @knipclassignore Indirectly consumed through Viewport. */ - public setDrawOffset(offset: Point2D): void { - this.#drawOffset.set(offset); + return this.#$drawOffset; } public requestRedraw() { @@ -1784,61 +1855,6 @@ export class Editor { return `${toolId}:${key}`; } - #resolveEditorPlacementOffset( - offset: Point2D, - glyphName: string | null, - unicode: number | null, - ): Point2D { - if (!glyphName || !isNonSpacingGlyph(glyphName, unicode)) { - return offset; - } - - const current = this.#$glyph.peek(); - if (!current || current.name !== glyphName) { - return offset; - } - - const metrics = this.font.getMetrics(); - const targetX = 300; - const targetYForAnchorName = (anchorName: string): number => { - switch (anchorName) { - case "top": - return metrics.capHeight ?? metrics.ascender; - case "bottom": - case "ogonek": - return 0; - case "center": - default: - return (metrics.ascender + metrics.descender) / 2; - } - }; - - const attachingAnchor = current.anchors.find((anchor) => { - const name = anchor.name ?? ""; - return name.startsWith("_") && name.length > 1; - }); - - if (attachingAnchor) { - const targetName = attachingAnchor.name!.slice(1); - return { - x: offset.x + (targetX - attachingAnchor.x), - y: offset.y + (targetYForAnchorName(targetName) - attachingAnchor.y), - }; - } - - const bounds = this.font.getBbox(glyphName); - if (!bounds) { - return offset; - } - - const centerX = (bounds.min.x + bounds.max.x) / 2; - const centerY = (bounds.min.y + bounds.max.y) / 2; - return { - x: offset.x + (targetX - centerX), - y: offset.y + ((metrics.ascender + metrics.descender) / 2 - centerY), - }; - } - #getToolScopeMap(scope: ToolStateScope): Map { return this.#toolState[scope]; } diff --git a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md index fcc34785..5b909f40 100644 --- a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md @@ -8,6 +8,8 @@ Central orchestrator for the canvas-based glyph editing surface, wiring viewport **Architecture Invariant:** Three coordinate spaces flow through every interaction: `screen` (canvas pixels, Y-down), `scene` (UPM with viewport transform applied), and `glyphLocal` (scene minus draw offset). All three are bundled in the `Coordinates` type; build one via `Editor.fromScreen()` / `fromScene()` / `fromGlyphLocal()` -- never compute one space from another manually. +**Architecture Invariant:** `drawOffset` is derived render state. Text tools focus glyphs by `GlyphAnchor { runId, cellId }`; `Editor` resolves that anchor through `TextRuns` and `TextLayout.editOriginForCell()`. Tools must not set text-run edit placement coordinates directly. + **Architecture Invariant: CRITICAL:** `ViewportManager` owns the affine matrices (`$upmToScreenMatrix`, `$screenToUpmMatrix`) as lazily computed signals. Anything that reads viewport-derived values inside a `computed` or `effect` will auto-track. Calling `setRect()`, changing zoom/pan, or changing UPM invalidates both matrices and triggers downstream redraws automatically. Never cache matrix results outside a signal. **Architecture Invariant: CRITICAL:** Rendering is driven by four reactive effects (`#staticEffect`, `#overlayEffect`, `#interactiveEffect`, `#cursorEffect`). Each effect reads the specific signals it depends on, then calls the corresponding `Viewport.request*Redraw()`. If you add new editor state that should trigger a redraw, you must read that signal inside the correct effect -- otherwise the canvas will not update. @@ -89,10 +91,12 @@ editor/ Screen (canvas pixels, Y-down) -> ViewportManager.projectScreenToScene() [affine matrix inverse] Scene (UPM space, Y-up, viewport-relative) - -> Editor.sceneToGlyphLocal() [subtract drawOffset] + -> Editor.sceneToGlyphLocal() [subtract layout-derived drawOffset] GlyphLocal (origin at glyph baseline-left) ``` +For direct glyph opens, `Editor.openGlyph()` creates a one-cell implicit editor run and focuses that cell. For text-run editing, `Editor.setGlyphFocus(anchor)` focuses the clicked cell. Both paths produce `drawOffset` through the same anchor-resolution pipeline. + `ViewportManager` computes the UPM-to-screen matrix as: baseline positioning + Y-flip + scale, composed with pan + zoom. The inverse is lazily computed. Both are `ComputedSignal` so any dependent computed/effect auto-invalidates. ### Four canvas layers diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts b/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts index e5dcac07..dcbbf0d0 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts @@ -28,7 +28,7 @@ let bridge: NativeBridge; beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A"); + bridge.startEditSession({ glyphName: "A" }); const glyph = bridge.$glyph.peek()!; glyph.addContour(); glyph.addPointToContour(glyph.activeContourId!, { diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts index 6f4dd20e..a7c4a96c 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts @@ -3,7 +3,7 @@ import type { Glyph } from "@/lib/model/Glyph"; import type { HandleState } from "@/types/graphics"; import type { Canvas } from "./Canvas"; import type { ViewportTransform } from "./Viewport"; -import { ReglHandleContext } from "@/lib/graphics/backends/ReglHandleContext"; +import { Gpu } from "@/lib/graphics/backends/Gpu"; import { packHandleInstances } from "./gpu/classifyHandles"; import { Vec2 } from "@shift/geo"; import { Validate } from "@shift/validation"; @@ -23,10 +23,10 @@ export interface HandleStates { * Falls back to CPU (Canvas 2D) rendering if WebGL is unavailable. */ export class Handles { - #gpu: ReglHandleContext | null = null; + #gpu: Gpu | null = null; #packedInstances: Float32Array | null = null; - setGpu(gpu: ReglHandleContext | null): void { + setGpu(gpu: Gpu | null): void { this.#gpu = gpu; } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts new file mode 100644 index 00000000..648a9f10 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts @@ -0,0 +1,104 @@ +/** + * TextRunRenderer — stateless per-frame draw class for the active text run. + * + * Same shape as the indicator drawing classes (Anchors, BoundingBox, etc.): + * no fields, methods take `(canvas, ...)`. The frame loop subscribes to the + * relevant signals (textRun.$layout / $caret / $selectionRects / interaction) + * and re-draws when they change. + * + * Coordinate space note: the canvas inside `renderToolScene` has been + * translated by `editor.drawOffset` (Viewport.ts:174) so that `glyph.draw` + * for the active glyph lands at world `drawOffset`. The TextLayout, by + * contrast, holds glyph positions in *world* (scene) UPM space — same space + * that `event.point` arrives in via `coords.scene`. + * + * The renderer reverses the drawOffset translate once at the top, then + * draws everything using world coords directly — same coords the layout + * produced and the hit-test consumed. + * + * Draw order: + * 1. selection rects (under glyphs) + * 2. glyphs (fill + optional hover outline; the cell being edited as a + * glyph is skipped — the editor draws that one separately at drawOffset) + * 3. caret (over glyphs) + */ +import type { Canvas } from "./Canvas"; +import type { Font } from "@/lib/model/Font"; +import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; +import type { Point2D } from "@shift/types"; + +export class Text { + draw( + canvas: Canvas, + run: TextRun, + font: Font, + drawOffset: Point2D, + focusedGlyph: FocusedGlyph | null, + ): void { + const layout = run.$layout.peek(); + if (!layout) return; + + const theme = canvas.theme.textRun; + + canvas.save(); + // Reverse the drawOffset translate so we draw in world UPM space. + canvas.translate(-drawOffset.x, -drawOffset.y); + + // Selection rects (under glyphs) + for (const rect of run.$selectionRects.peek()) { + canvas.fillRect(rect.x, rect.bottom, rect.width, rect.top - rect.bottom, theme.selectionFill); + } + + // Glyphs + const focusedCellId = focusedGlyph?.anchor.runId === run.id ? focusedGlyph.anchor.cellId : null; + const hoveredCluster = run.interaction.hoveredIndex; + + for (const line of layout.lines) { + let runBase = layout.origin.x; + for (const r of line.runs) { + for (const g of r.glyphs) { + // Skip the cell being edited as a glyph — the editor draws that one + // at its drawOffset via the standard glyph render path. + if (focusedCellId && g.cellIds.includes(focusedCellId)) { + continue; + } + + // GlyphView.$path is a cached Path2D — only re-built when the + // variation location moves (or the glyph's geometry changes). + // The Editor's staticEffect already tracks $variationLocation + // and requests a scene redraw, so peek() is correct here. + const view = font.glyph(g.glyphName); + if (view) { + const path = view.$path.peek(); + canvas.save(); + canvas.translate(runBase + g.origin.x + g.xOffset, line.y + g.origin.y + g.yOffset); + canvas.fillPath(path, canvas.theme.glyph.fill); + if (g.cluster === hoveredCluster) { + canvas.strokePath(path, theme.hoverOutline, theme.hoverOutlineWidthPx); + } + canvas.restore(); + } + } + runBase += r.advance; + } + } + + // Caret (over glyphs) + if (run.cursorVisible) { + const caret = run.$caret.peek(); + if (caret) { + const pos = caret.position(); + const top = pos.y + layout.metrics.ascender; + const bottom = pos.y + layout.metrics.descender; + canvas.line( + { x: pos.x, y: top }, + { x: pos.x, y: bottom }, + theme.cursorColor, + theme.cursorWidthPx, + ); + } + } + + canvas.restore(); + } +} diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts index 2260bf87..b1a42a99 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts @@ -4,7 +4,7 @@ import { DEFAULT_THEME } from "./Theme"; import { Canvas } from "./Canvas"; import { FrameHandler } from "./FrameHandler"; import { FpsMonitor } from "./FpsMonitor"; -import { ReglHandleContext } from "@/lib/graphics/backends/ReglHandleContext"; +import { Gpu } from "@/lib/graphics/backends/Gpu"; import type { Editor } from "../Editor"; /** @@ -44,7 +44,7 @@ export class Viewport { overlay: Canvas | null; } = { background: null, scene: null, overlay: null }; - #gpuHandleContext: ReglHandleContext | null = null; + #gpuHandleContext: Gpu | null = null; #backgroundFrame = new FrameHandler(); #sceneFrame = new FrameHandler(); @@ -77,11 +77,11 @@ export class Viewport { this.#canvases.overlay = null; } - setGpuHandleContext(context: ReglHandleContext): void { + setGpuHandleContext(context: Gpu): void { this.#gpuHandleContext = context; } - get gpuHandleContext(): ReglHandleContext | null { + get gpuHandleContext(): Gpu | null { return this.#gpuHandleContext; } diff --git a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts index 3f210617..59f54633 100644 --- a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts @@ -23,13 +23,13 @@ describe("Editor.open — variation-aware edit sessions", () => { const editor = new TestEditor(); editor.font.load(MUTATORSANS_DESIGNSPACE); - const atDefault = editor.open("A")!; + const atDefault = editor.open({ glyphName: "A", unicode: 65 })!; const defaultAdvance = atDefault.xAdvance; const defaultPoints = flattenPoints(atDefault); editor.close(); editor.font.setVariationLocation(boldLocation(editor)); - const atBold = editor.open("A")!; + const atBold = editor.open({ glyphName: "A", unicode: 65 })!; expect(atBold.xAdvance).not.toBe(defaultAdvance); expect(flattenPoints(atBold)).not.toEqual(defaultPoints); diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/ReglHandleContext.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/Gpu.ts similarity index 99% rename from apps/desktop/src/renderer/src/lib/graphics/backends/ReglHandleContext.ts rename to apps/desktop/src/renderer/src/lib/graphics/backends/Gpu.ts index b0c8c36d..fcfb05fa 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/ReglHandleContext.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/Gpu.ts @@ -5,7 +5,7 @@ import frag from "@/lib/editor/rendering/gpu/shaders/handle.frag.glsl"; const UNIT_QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]); -export class ReglHandleContext { +export class Gpu { #regl: REGL.Regl | null = null; #instanceBuffer: REGL.Buffer | null = null; #drawCommand: REGL.DrawCommand | null = null; diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts index c34a3852..cbd712be 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts @@ -98,7 +98,7 @@ describe("GlyphView — variation interpolation", () => { // (font.glyph(name).componentContours()) picks up new component geometry. const editor = new TestEditor(); editor.font.load(MUTATORSANS_DESIGNSPACE); - editor.open("Aacute"); + editor.open({ glyphName: "Aacute", unicode: 0x00c1 }); const view = editor.font.glyph("Aacute")!; const atDefault = flattenComponentCoords(view); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts b/apps/desktop/src/renderer/src/lib/model/GlyphView.ts index 680c07a7..b87ffdcc 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphView.ts @@ -1,6 +1,7 @@ import type { Axis, AxisLocation, + Anchor, Component, DecomposedTransform, GlyphGeometry, @@ -75,7 +76,9 @@ export class GlyphView { readonly #font: Font; readonly #values: ComputedSignal; readonly #svgPath: ComputedSignal; + readonly #path: ComputedSignal; readonly #advance: ComputedSignal; + readonly #bounds: ComputedSignal; constructor( name: string, @@ -103,7 +106,17 @@ export class GlyphView { $location.value; return buildSvgPath(this.contours()); }); + // Cached Path2D — re-built only when $svgPath fires (variation scrub or + // geometry mutation). Constructing a Path2D from an SVG string is the + // hot path during text-run draws; this turns per-frame allocation + // into a one-time cost per variation tick. + this.#path = computed(() => new Path2D(this.#svgPath.value)); this.#advance = computed(() => this.#values.value[0]); + // Cached bbox — `font.getBbox` is a NAPI call into Rust; positioner asks + // every layout rebuild, so without this it dominates the variation-scrub + // hot path. Wrapped in a computed (with no tracked deps today) so it + // upgrades cleanly when variation-aware bounds land. + this.#bounds = computed(() => this.#font.getBbox(this.name) ?? null); } get advance(): number { @@ -114,15 +127,30 @@ export class GlyphView { return this.#svgPath; } + get $path(): Signal { + return this.#path; + } + get $advance(): Signal { return this.#advance; } - get bounds(): Bounds | undefined { - const bounds = this.#font.getBbox(this.name); - if (!bounds) return; + get bounds(): Bounds | null { + return this.#bounds.value; + } + + get anchors(): readonly Anchor[] { + const v = this.#values.value; + let cursor = 1; + for (const contour of this.#geometry.contours) { + cursor += contour.points.length * 2; + } - return bounds; + return this.#geometry.anchors.map((anchor, index) => ({ + ...anchor, + x: v[cursor + index * 2], + y: v[cursor + index * 2 + 1], + })); } /** @@ -180,6 +208,7 @@ export class GlyphView { this.#values.dispose(); this.#svgPath.dispose(); this.#advance.dispose(); + this.#bounds.dispose(); } } diff --git a/apps/desktop/src/renderer/src/lib/model/glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/glyph.test.ts index 6d7007b9..fe0056c5 100644 --- a/apps/desktop/src/renderer/src/lib/model/glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/glyph.test.ts @@ -10,7 +10,7 @@ let glyph: Glyph; beforeEach(() => { bridge = createBridge(); - bridge.startEditSession("A", 65); + bridge.startEditSession({ glyphName: "A", unicode: 65 }); glyph = bridge.$glyph.peek()!; }); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts b/apps/desktop/src/renderer/src/lib/text/TextBuffer.test.ts similarity index 76% rename from apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts rename to apps/desktop/src/renderer/src/lib/text/TextBuffer.test.ts index 3f87e315..9cfc8225 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextBuffer.test.ts @@ -20,9 +20,10 @@ describe("TextBuffer", () => { // SEED — insert appends and advances cursor + anchor. it("insert places cell at cursor and advances both cursor and anchor", () => { - buffer.insert(glyph("A")); + const a = glyph("A"); + buffer.insert(a); - expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cells).toEqual([a]); expect(buffer.cursor).toBe(1); expect(buffer.anchor).toBe(1); expect(buffer.hasSelection).toBe(false); @@ -32,23 +33,28 @@ describe("TextBuffer", () => { // insert(X) // after: [A, X] cursor = anchor = 2 (X collapses BC + caret advances) it("insert replaces an active selection", () => { - buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + const a = glyph("A"); + const b = glyph("B"); + const c = glyph("C"); + const x = glyph("X"); + buffer.insertMany([a, b, c]); buffer.selectRange(1, 3); - buffer.insert(glyph("X")); + buffer.insert(x); - expect(buffer.cells).toEqual([glyph("A"), glyph("X")]); + expect(buffer.cells).toEqual([a, x]); expect(buffer.cursor).toBe(2); expect(buffer.anchor).toBe(2); }); // SEED — backspace with no selection removes one cell before cursor. it("delete removes cell before cursor when there is no selection", () => { - buffer.insertMany([glyph("A"), glyph("B")]); // cursor=2 + const a = glyph("A"); + buffer.insertMany([a, glyph("B")]); // cursor=2 expect(buffer.delete()).toBe(true); - expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cells).toEqual([a]); expect(buffer.cursor).toBe(1); }); @@ -62,12 +68,13 @@ describe("TextBuffer", () => { // delete() // after: [A] cursor = anchor = 1 (selection removed, caret at start) it("delete with active selection removes it and collapses to start", () => { - buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + const a = glyph("A"); + buffer.insertMany([a, glyph("B"), glyph("C")]); buffer.selectRange(1, 3); expect(buffer.delete()).toBe(true); - expect(buffer.cells).toEqual([glyph("A")]); + expect(buffer.cells).toEqual([a]); expect(buffer.cursor).toBe(1); expect(buffer.anchor).toBe(1); }); @@ -98,7 +105,8 @@ describe("TextBuffer", () => { // SEED — snapshot / restore round-trip. it("snapshot then restore reproduces buffer state", () => { - buffer.insertMany([glyph("A"), glyph("B"), glyph("C")]); + const cells = [glyph("A"), glyph("B"), glyph("C")]; + buffer.insertMany(cells); buffer.selectRange(1, 3); buffer.setOriginX(42); @@ -107,9 +115,24 @@ describe("TextBuffer", () => { const fresh = new TextBuffer(); fresh.restore(snap); - expect(fresh.cells).toEqual([glyph("A"), glyph("B"), glyph("C")]); + expect(fresh.cells).toEqual(cells); expect(fresh.anchor).toBe(1); expect(fresh.cursor).toBe(3); expect(fresh.originX).toBe(42); }); + + it("cellById follows the same logical cell through insertions and deletion", () => { + const b = glyph("B"); + buffer.insertMany([glyph("A"), b]); + + buffer.placeCaret(0); + buffer.insert(glyph("X")); + + expect(buffer.cellById(b.id)).toBe(b); + + buffer.selectRange(2, 3); + buffer.delete(); + + expect(buffer.cellById(b.id)).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts b/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts similarity index 94% rename from apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts rename to apps/desktop/src/renderer/src/lib/text/TextBuffer.ts index b323c822..c5d95422 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextBuffer.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts @@ -19,7 +19,7 @@ * `TextRun`. */ import { signal, batch, type WritableSignal, type Signal } from "@/lib/reactive/signal"; -import type { Cell } from "./layout"; +import type { Cell, TextCellId } from "./layout"; import { clamp } from "@/lib/utils/utils"; export interface SelectionRange { @@ -105,6 +105,10 @@ export class TextBuffer { return this.#$cells.peek().slice(start, end); } + cellById(id: TextCellId): Cell | null { + return this.#$cells.value.find((cell) => cell.id === id) ?? null; + } + /** Raw signals for React hooks that need `Signal`. */ get $cells(): Signal { return this.#$cells; @@ -162,8 +166,14 @@ export class TextBuffer { * Existing cursor/anchor shift right by 1 if they sit at or after `index`. */ /** @knipclassignore — used by Select tool's TextRunEdit splice (TODO) */ - insertAt(_index: number, _cell: Cell): void { - throw new Error("TextBuffer.insertAt not implemented"); + insertAt(index: number, cell: Cell): void { + const at = clamp(index, 0, this.length); + const next = [...this.cells.slice(0, at), cell, ...this.cells.slice(at)]; + this.#update({ + cells: next, + cursor: this.cursor >= at ? this.cursor + 1 : this.cursor, + anchor: this.anchor >= at ? this.anchor + 1 : this.anchor, + }); } /** diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts b/apps/desktop/src/renderer/src/lib/text/TextInteraction.test.ts similarity index 88% rename from apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts rename to apps/desktop/src/renderer/src/lib/text/TextInteraction.test.ts index 5c0aa4b5..b5994a90 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextInteraction.test.ts @@ -16,9 +16,10 @@ describe("TextInteraction", () => { }); it("setEditing stores the target", () => { - ctx.setEditing({ index: 3, cell: glyph("A", 65) }); + const target = { index: 3, cell: glyph("A", 65) }; + ctx.setEditing(target); - expect(ctx.editing).toEqual({ index: 3, cell: glyph("A", 65) }); + expect(ctx.editing).toEqual(target); }); it("suspend moves editing into suspended", () => { @@ -92,9 +93,11 @@ describe("TextInteraction", () => { }); it("snapshot then restore reproduces context state", () => { - ctx.setEditing({ index: 4, cell: glyph("A", 65) }); + const suspended = { index: 4, cell: glyph("A", 65) }; + const editing = { index: 1, cell: glyph("B", 66) }; + ctx.setEditing(suspended); ctx.suspend(); - ctx.setEditing({ index: 1, cell: glyph("B", 66) }); + ctx.setEditing(editing); ctx.setHovered(3); const snap = ctx.snapshot(); @@ -102,8 +105,8 @@ describe("TextInteraction", () => { const fresh = new TextInteraction(); fresh.restore(snap); - expect(fresh.editing).toEqual({ index: 1, cell: glyph("B", 66) }); - expect(fresh.suspended).toEqual({ index: 4, cell: glyph("A", 65) }); + expect(fresh.editing).toEqual(editing); + expect(fresh.suspended).toEqual(suspended); expect(fresh.hoveredIndex).toBe(3); }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts b/apps/desktop/src/renderer/src/lib/text/TextInteraction.ts similarity index 100% rename from apps/desktop/src/renderer/src/lib/tools/text/TextInteraction.ts rename to apps/desktop/src/renderer/src/lib/text/TextInteraction.ts diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts b/apps/desktop/src/renderer/src/lib/text/TextRun.ts similarity index 57% rename from apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts rename to apps/desktop/src/renderer/src/lib/text/TextRun.ts index b9271333..dc4bfefc 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRun.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextRun.ts @@ -17,9 +17,11 @@ import { signal, computed, type Signal, type ComputedSignal } from "@/lib/reactive/signal"; import { TextBuffer } from "./TextBuffer"; import { TextInteraction } from "./TextInteraction"; -import { Caret, TextLayout } from "./layout"; -import type { Cell, Positioner } from "./layout"; +import { Caret, glyphCell, TextLayout } from "./layout"; +import type { Cell, GlyphAnchor, GlyphCell, Positioner, TextRunId } from "./layout"; import type { Font } from "@/lib/model/Font"; +import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; +import type { Point2D } from "@shift/types"; export interface SelectionRect { x: number; @@ -28,7 +30,15 @@ export interface SelectionRect { bottom: number; } +export interface FocusedGlyph { + anchor: GlyphAnchor; + cell: GlyphCell; + glyph: GlyphHandle; + editOrigin: Point2D; +} + export class TextRun { + readonly id: TextRunId; readonly buffer: TextBuffer; readonly interaction: TextInteraction; readonly #font: Font; @@ -41,7 +51,8 @@ export class TextRun { #goalX: number | null = null; - constructor(font: Font, positioner: Positioner) { + constructor(id: TextRunId, font: Font, positioner: Positioner) { + this.id = id; this.buffer = new TextBuffer(); this.interaction = new TextInteraction(); this.#font = font; @@ -99,9 +110,14 @@ export class TextRun { /** * Initialize the run with a single seed cell at originX. Combines * `buffer.seed` + `buffer.setOriginX` so Text-tool activation is one call. - * No-op on the seed if the buffer already has cells; originX is always set. + * + * No-op when the buffer already has cells. Critically, this means originX + * is *not* re-applied on re-activation — without that guard, the run + * shifts every time the user toggles between Select and Text after + * drawOffset has moved (e.g. via double-clicking a slot to edit it). */ seed(cell: Cell, originX: number): void { + if (this.buffer.length > 0) return; this.buffer.seed(cell); this.buffer.setOriginX(originX); } @@ -168,6 +184,58 @@ export class TextRun { this.#resetGoalX(); } + anchorAtPoint(p: Point2D, padding = 0): GlyphAnchor | null { + const layout = this.#$layout.peek(); + return layout?.anchorAtPoint(this.id, p, padding) ?? null; + } + + /** + * Resolve a durable glyph anchor through current buffer and layout state. + * + * run = [a(id=a1), a(id=a2), s(id=s1), d(id=d1)] + * ▲ + * click + * │ + * ▼ + * GlyphAnchor { runId: run.id, cellId: s1 } + * │ + * ▼ + * resolveAnchor(anchor) + * │ reads current buffer + current layout + * ▼ + * FocusedGlyph { glyph: "s", editOrigin } + */ + resolveAnchor(anchor: GlyphAnchor): FocusedGlyph | null { + if (anchor.runId !== this.id) return null; + const cell = this.buffer.cellById(anchor.cellId); + if (!cell || cell.kind !== "glyph") return null; + + const editOrigin = this.#$layout.peek()?.editOriginForCell(anchor.cellId) ?? null; + if (!editOrigin) return null; + + return { + anchor, + cell, + glyph: { + glyphName: cell.glyphName, + ...(cell.codepoint !== null ? { unicode: cell.codepoint } : {}), + }, + editOrigin, + }; + } + + setSingleGlyph(handle: GlyphHandle): GlyphAnchor { + const cell = glyphCell(handle.glyphName, handle.unicode ?? null); + this.buffer.restore({ + cells: [cell], + cursor: 1, + anchor: 1, + originX: 0, + }); + this.interaction.clear(); + return { runId: this.id, cellId: cell.id }; + } + /** @knipclassignore — keyboard nav via HiddenTextInput */ moveCursorLeft(extend = false): void { this.#resetGoalX(); @@ -184,36 +252,105 @@ export class TextRun { else this.buffer.placeCaret(next); } - // Vertical nav: deferred. No-op for now; lands when Caret.nextLine ships. /** @knipclassignore — keyboard nav via HiddenTextInput */ - moveCursorUp(_extend = false): void { - void this.#goalX; // TODO: read as goalX seed for Caret.previousLine + moveCursorUp(extend = false): void { + const caret = this.#$caret.peek(); + if (!caret) return; + this.#goalX ??= caret.position().x; + const next = caret.previousLine(this.#goalX); + if (extend) this.buffer.extendSelection(next.cluster); + else this.buffer.placeCaret(next.cluster); } /** @knipclassignore — keyboard nav via HiddenTextInput */ - moveCursorDown(_extend = false): void { - /* TODO: Caret.nextLine(this.#goalX ??= caret.position().x) */ + moveCursorDown(extend = false): void { + const caret = this.#$caret.peek(); + if (!caret) return; + this.#goalX ??= caret.position().x; + const next = caret.nextLine(this.#goalX); + if (extend) this.buffer.extendSelection(next.cluster); + else this.buffer.placeCaret(next.cluster); } - // Word and line-edge nav: deferred. No-op for now. /** @knipclassignore — keyboard nav via HiddenTextInput */ - moveCursorByWord(_direction: -1 | 1, _extend = false): void { - /* TODO: walk cells looking for word boundaries via codepoint classes */ + moveCursorByWord(direction: -1 | 1, extend = false): void { + this.#resetGoalX(); + const cells = this.buffer.cells; + let pos = this.buffer.cursor; + + if (direction === -1) { + if (pos <= 0) return; + pos--; + while (pos > 0 && isWhitespaceCell(cells[pos - 1])) pos--; + while (pos > 0 && !isWhitespaceCell(cells[pos - 1]) && !isPunctuationCell(cells[pos - 1])) { + pos--; + } + } else { + if (pos >= cells.length) return; + while ( + pos < cells.length && + !isWhitespaceCell(cells[pos]) && + !isPunctuationCell(cells[pos]) + ) { + pos++; + } + // while (pos < cells.length && isWhitespaceCell(cells[pos])) pos++; + } + + if (extend) this.buffer.extendSelection(pos); + else this.buffer.placeCaret(pos); } /** @knipclassignore — keyboard nav via HiddenTextInput */ - moveCursorToLineStart(_extend = false): void { - /* TODO: find current line, set cursor to its clusterStart */ + moveCursorToLineStart(extend = false): void { + this.#resetGoalX(); + const lineIdx = this.#findCurrentLineIndex(); + if (lineIdx < 0) return; + const target = this.#$layout.peek()!.lines[lineIdx].clusterStart; + if (extend) this.buffer.extendSelection(target); + else this.buffer.placeCaret(target); } /** @knipclassignore — keyboard nav via HiddenTextInput */ - moveCursorToLineEnd(_extend = false): void { - /* TODO: find current line, set cursor to its last caret position */ + moveCursorToLineEnd(extend = false): void { + this.#resetGoalX(); + const lineIdx = this.#findCurrentLineIndex(); + if (lineIdx < 0) return; + const target = this.#$layout.peek()!.lines[lineIdx].clusterEnd - 1; + if (extend) this.buffer.extendSelection(target); + else this.buffer.placeCaret(target); } #resetGoalX(): void { this.#goalX = null; } + + #findCurrentLineIndex(): number { + const layout = this.#$layout.peek(); + if (!layout) return -1; + const cursor = this.buffer.cursor; + for (const [i, line] of layout.lines.entries()) { + if (cursor >= line.clusterStart && cursor < line.clusterEnd) return i; + } + return -1; + } +} + +function isWhitespaceCell(cell: Cell): boolean { + if (cell.kind === "linebreak") return true; + if (cell.codepoint === null) return false; + return cell.codepoint === 0x20 || cell.codepoint === 0x09 || cell.codepoint === 0x0a; +} + +function isPunctuationCell(cell: Cell): boolean { + if (cell.kind !== "glyph" || cell.codepoint === null) return false; + const cp = cell.codepoint; + return ( + (cp >= 0x21 && cp <= 0x2f) || + (cp >= 0x3a && cp <= 0x40) || + (cp >= 0x5b && cp <= 0x60) || + (cp >= 0x7b && cp <= 0x7e) + ); } function computeSelectionRects( @@ -222,19 +359,19 @@ function computeSelectionRects( ): SelectionRect[] { const rects: SelectionRect[] = []; for (const line of layout.lines) { - let cursor = layout.origin.x; + let runBase = layout.origin.x; let rectStart: number | null = null; - let rectEnd = cursor; + let rectEnd = runBase; for (const run of line.runs) { for (const g of run.glyphs) { - const left = cursor; - const right = cursor + g.xAdvance; + const left = runBase + g.origin.x; + const right = left + g.xAdvance; if (g.cluster >= range.start && g.cluster < range.end) { if (rectStart === null) rectStart = left; rectEnd = right; } - cursor = right; } + runBase += run.advance; } if (rectStart !== null) { rects.push({ diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts similarity index 60% rename from apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts rename to apps/desktop/src/renderer/src/lib/text/TextRuns.ts index c45d1394..72b1b37b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRuns.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts @@ -16,11 +16,14 @@ import { type ComputedSignal, } from "@/lib/reactive/signal"; import { TextRun } from "./TextRun"; +import type { FocusedGlyph } from "./TextRun"; import type { Positioner } from "./layout"; import type { Font } from "@/lib/model/Font"; import type { TextBufferSnapshot } from "./TextBuffer"; +import type { GlyphAnchor } from "./layout"; const DEFAULT_RUN_KEY = "__default__"; +export const EDITOR_RUN_ID = "__editor__"; export interface PersistedTextRun { buffer: TextBufferSnapshot; @@ -32,12 +35,14 @@ export class TextRuns { readonly #$active: ComputedSignal; readonly #font: Font; readonly #positioner: Positioner; + readonly #editorRun: TextRun; constructor(font: Font, positioner: Positioner) { this.#runs = new Map(); this.#$activeKey = signal(DEFAULT_RUN_KEY); this.#font = font; this.#positioner = positioner; + this.#editorRun = new TextRun(EDITOR_RUN_ID, this.#font, this.#positioner); this.#$active = computed(() => this.#getOrCreate(this.#$activeKey.value)); } @@ -51,6 +56,27 @@ export class TextRuns { return this.#$active; } + /** + * Return the implicit one-glyph run used by direct glyph editing. + * + * openGlyph(S) + * │ + * ▼ + * editorRun = [S(id=s1)] + * │ + * ▼ + * GlyphAnchor { runId: "__editor__", cellId: s1 } + * │ + * ▼ + * TextLayout.editOriginForCell(s1) + * │ + * ▼ + * drawOffset + */ + editorRun(): TextRun { + return this.#editorRun; + } + /** * Switch active run to the one keyed by `glyphName` (or default if null). * Returns the now-active run for chaining. @@ -71,6 +97,17 @@ export class TextRuns { /** Drop every run. */ clearAll(): void { this.#runs.clear(); + this.#editorRun.buffer.clear(); + this.#editorRun.interaction.clear(); + } + + get(runId: string): TextRun | null { + if (runId === EDITOR_RUN_ID) return this.#editorRun; + return this.#runs.get(runId) ?? null; + } + + resolveAnchor(anchor: GlyphAnchor): FocusedGlyph | null { + return this.get(anchor.runId)?.resolveAnchor(anchor) ?? null; } serialize(): Record { @@ -88,19 +125,28 @@ export class TextRuns { deserialize(persisted: Record): void { this.#runs.clear(); for (const [key, entry] of Object.entries(persisted)) { - const run = new TextRun(this.#font, this.#positioner); + const run = new TextRun(key, this.#font, this.#positioner); run.buffer.restore(entry.buffer); this.#runs.set(key, run); } - if (!this.#runs.has(this.#$activeKey.peek())) { - this.#$activeKey.set(DEFAULT_RUN_KEY); - } + + // Force `$active` to re-resolve from the now-populated Map. Without this, + // any consumer that already read `$active.value` before deserialize ran + // (e.g., the Editor's auto-save effect during construction) holds a + // stale reference to a pre-load empty TextRun — and any subsequent fire + // of the effect serializes that empty state back over the loaded data. + // The Map itself isn't a signal, so we toggle `$activeKey` through a + // sentinel to bypass the computed's equality skip. + const key = this.#$activeKey.peek(); + const targetKey = this.#runs.has(key) ? key : DEFAULT_RUN_KEY; + this.#$activeKey.set("__force_recompute__"); + this.#$activeKey.set(targetKey); } #getOrCreate(key: string): TextRun { let run = this.#runs.get(key); if (run) return run; - run = new TextRun(this.#font, this.#positioner); + run = new TextRun(key, this.#font, this.#positioner); this.#runs.set(key, run); return run; } diff --git a/apps/desktop/src/renderer/src/lib/text/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/text/docs/DOCS.md new file mode 100644 index 00000000..cb3c03b9 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/text/docs/DOCS.md @@ -0,0 +1,74 @@ +# Text + +Text editing is split into stable editor identity and derived layout geometry. + +## Architecture Invariants + +**Architecture Invariant:** `TextCellId` is the durable identity for editable text cells. Buffer indices and layout clusters may move after insert/delete operations; focus must remain attached to `cell.id`. + +**Architecture Invariant:** HarfBuzz clusters are layout metadata, not editor identity. Shaped layout must map output glyphs back to `TextCellId[]`. + +**Architecture Invariant:** `TextLayout` owns identity-to-geometry resolution. Call `editOriginForCell(cellId)` to get the current scene-space edit origin; do not cache text-run placement coordinates in tools. + +**Architecture Invariant:** Direct glyph editing uses the implicit editor run (`TextRuns.editorRun()`). Real text runs and the implicit editor run share anchor/focus/placement machinery, but only real text runs are persisted as user text content. + +## Core Flow + +``` +TextBuffer cells + -> TextLayout positioned glyphs { cellIds, origin, xOffset/yOffset } + -> GlyphAnchor { runId, cellId } + -> TextRuns.resolveAnchor() + -> FocusedGlyph.editOrigin + -> Editor.drawOffset +``` + +## Key Types + +- **`TextCellId`** -- stable identity for a glyph or linebreak cell. +- **`GlyphAnchor`** -- `{ runId, cellId }`; the durable bridge from editor focus to current layout. +- **`FocusedGlyph`** -- resolved anchor with the current cell, glyph handle, and edit origin. +- **`PositionedGlyph.cellIds`** -- source cell identities covered by a positioned glyph. Simple layout is one-to-one; shaped layout may be many-to-one or one-to-many. +- **`Positioner`** -- current simple source-order layout implementation. It owns display advance and mark offset logic so editor placement is layout-derived. + +## Direct Glyph Open + +``` +openGlyph(S) + | + v +editorRun = [S(id=s1)] + | + v +GlyphAnchor { runId: "__editor__", cellId: s1 } + | + v +TextLayout.editOriginForCell(s1) + | + v +drawOffset +``` + +## Text-Run Focus + +``` +run = [a(id=a1), a(id=a2), s(id=s1), d(id=d1)] + ^ + click + | + v +GlyphAnchor { runId: run.id, cellId: s1 } + | + v +resolveAnchor(anchor) + | reads current buffer + current layout + v +FocusedGlyph { glyph: "s", editOrigin } + | + v +drawOffset = editOrigin +``` + +## Persistence + +Persisted text cells must include IDs. There is no legacy repair path in the renderer: missing IDs are invalid persisted data. diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts similarity index 53% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts rename to apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts index 93a1784e..9e78c9e0 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { Font } from "@/lib/model/Font"; import { Caret } from "./Caret"; -import { glyphCell as glyph, linebreak } from "./types"; +import { glyphCell as glyph, linebreakCell } from "./types"; import { loadTestFont, makeLayout } from "./testUtils"; describe("Caret", () => { @@ -40,7 +40,7 @@ describe("Caret", () => { // Caret 0 (before A) → 1 (end of line 1, before linebreak) // → 2 (start of line 2, before B) it("next steps through paragraph boundary", () => { - const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + const layout = makeLayout([glyph("A", 65), linebreakCell(), glyph("B", 66)], font); const metrics = font.getMetrics(); const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); let c = Caret.atCluster(layout, 0); @@ -61,4 +61,39 @@ describe("Caret", () => { expect(start.previous().cluster).toBe(0); }); + + // Regression: pressing Enter at the end of a buffer puts the caret on the + // empty line *after* the linebreak (line 1 baseline), not back at origin + // on line 0. Buffer = [A, \n] has bufferLength = 2; cluster 2 is the + // empty trailing paragraph. + // + // line 0 A ⏎ ← cluster 0 = before A; cluster 1 = end of line 0 + // line 1 cluster 2 = empty line 1 (caret sits at originX) + it("position on empty trailing line lands at that line's baseline", () => { + const layout = makeLayout([glyph("A", 65), linebreakCell()], font); + const metrics = font.getMetrics(); + const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); + const caret = Caret.atCluster(layout, 2); + + const pos = caret.position(); + expect(pos.x).toBe(layout.origin.x); + expect(pos.y).toBe(-lineHeight); + }); + + // Regression: caret between two consecutive linebreaks lands on the empty + // middle line, not line 0. + // + // line 0 ⏎ cluster 0 + // line 1 ⏎ cluster 1 ← we want this + // line 2 cluster 2 + it("position on empty line between two linebreaks lands on the middle line", () => { + const layout = makeLayout([linebreakCell(), linebreakCell()], font); + const metrics = font.getMetrics(); + const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); + const caret = Caret.atCluster(layout, 1); + + const pos = caret.position(); + expect(pos.x).toBe(layout.origin.x); + expect(pos.y).toBe(-lineHeight); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts b/apps/desktop/src/renderer/src/lib/text/layout/Caret.ts similarity index 50% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts rename to apps/desktop/src/renderer/src/lib/text/layout/Caret.ts index a7ef2a79..c253377e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/Caret.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Caret.ts @@ -58,10 +58,17 @@ export class Caret { const m = layout.metrics; const lineHeight = m.ascender - m.descender + (m.lineGap ?? 0); + // Direct: cluster has a positioned glyph → its leading edge. const direct = layout.pointAt(this.cluster); if (direct) return direct; + // Find which line owns this cluster (uses clusterStart / clusterEnd + // so empty paragraphs land on their own line, not line 0). for (const line of layout.lines) { + if (this.cluster < line.clusterStart || this.cluster >= line.clusterEnd) continue; + + // On this line. Walk to find the trailing edge of the cell whose + // cluster + 1 === this.cluster (i.e. caret sits right after that cell). let cursor = layout.origin.x; for (const run of line.runs) { for (const g of run.glyphs) { @@ -71,26 +78,77 @@ export class Caret { cursor += g.xAdvance; } } + + // Empty line (no glyphs to walk). Caret sits at line origin. + return { x: layout.origin.x, y: line.y, lineHeight }; } + // Cluster doesn't fall on any line (empty buffer or out of range). return { x: layout.origin.x, y: layout.origin.y, lineHeight }; } - /** @knipclassignore — vertical nav, deferred to follow-up */ - nextLine(_goalX: number): Caret { - throw new Error("Caret.nextLine not implemented"); - } - - /** @knipclassignore — vertical nav, deferred to follow-up */ - previousLine(_goalX: number): Caret { - throw new Error("Caret.previousLine not implemented"); + /** + * Vertical nav: caret on the next line, choosing the cluster whose canvas + * x is closest to `goalX`. Threading goalX through consecutive Up/Down + * presses preserves horizontal position across short lines. + * + * If already on the last line: clamps to end-of-current-line (VSCode-style). + */ + nextLine(goalX: number): Caret { + const idx = this.#findLineIndex(); + if (idx < 0) return this; + const targetIdx = idx + 1; + if (targetIdx >= this.layout.lines.length) { + return Caret.atCluster(this.layout, this.layout.lines[idx].clusterEnd - 1); + } + return Caret.atCluster(this.layout, this.#nearestClusterOnLine(targetIdx, goalX)); } /** - * True when the caret sits at a paragraph or buffer boundary. - * @knipclassignore — used by selection extension logic (TODO) + * Vertical nav: caret on the previous line. If already on the first line: + * clamps to start-of-current-line. */ - isBoundary(): boolean { - throw new Error("Caret.isBoundary not implemented"); + previousLine(goalX: number): Caret { + const idx = this.#findLineIndex(); + if (idx < 0) return this; + const targetIdx = idx - 1; + if (targetIdx < 0) { + return Caret.atCluster(this.layout, this.layout.lines[idx].clusterStart); + } + return Caret.atCluster(this.layout, this.#nearestClusterOnLine(targetIdx, goalX)); + } + + #findLineIndex(): number { + for (const [i, line] of this.layout.lines.entries()) { + if (this.cluster >= line.clusterStart && this.cluster < line.clusterEnd) return i; + } + return -1; + } + + #nearestClusterOnLine(lineIdx: number, goalX: number): number { + const line = this.layout.lines[lineIdx]; + + let bestCluster = line.clusterStart; + let bestDist = Infinity; + let cursor = this.layout.origin.x; + + for (const run of line.runs) { + for (const g of run.glyphs) { + const leadingDist = Math.abs(cursor - goalX); + if (leadingDist < bestDist) { + bestDist = leadingDist; + bestCluster = g.cluster; + } + const trailingX = cursor + g.xAdvance; + const trailingDist = Math.abs(trailingX - goalX); + if (trailingDist < bestDist) { + bestDist = trailingDist; + bestCluster = g.cluster + 1; + } + cursor += g.xAdvance; + } + } + + return bestCluster; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts similarity index 92% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts rename to apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts index 1675628d..87dc6c6c 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts @@ -36,11 +36,14 @@ describe("Positioner", () => { // Each positioned glyph carries the bounds the font reports. it("bounds pass through from font.getBbox", () => { const positioner = new Positioner(); - const run = ltrRun([glyph("A", 65)]); + const a = glyph("A", 65); + const run = ltrRun([a]); const positioned = positioner.position(run, font); expect(positioned.glyphs[0].bounds).toEqual(font.getBbox("A")); + expect(positioned.glyphs[0].cellIds).toEqual([a.id]); + expect(positioned.glyphs[0].origin).toEqual({ x: 0, y: 0 }); }); // Glyph not in the font → zero advance, null bounds, no throw. diff --git a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts new file mode 100644 index 00000000..844acd76 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts @@ -0,0 +1,91 @@ +import { displayAdvance, isNonSpacingGlyph } from "@/lib/utils/unicode"; +import type { GlyphCell, PositionedRun, SegmentedRun } from "./types"; +import { Font } from "@/lib/model/Font"; +import type { Point2D } from "@shift/types"; + +/** + * No-shape positioner — literal LTR advance walk, `cluster = clusterStart + i`. + * Permanent product mode for editing scripts where the user wants source-order + * glyph display without joining/contextual substitution (e.g. Arabic + * side-by-side editing). + * + * + */ +export class Positioner { + position(run: SegmentedRun, font: Font): PositionedRun { + let totalAdvance = 0; + + const glyphs = run.glyphs.map((g, idx) => { + const glyph = font.glyph(g.glyphName); + const xAdvance = resolveAdvance(g, font); + const origin = { x: totalAdvance, y: 0 }; + const offset = resolveGlyphOffset(g, font); + totalAdvance += xAdvance; + + return { + glyphName: glyph?.name ?? g.glyphName, + cellIds: [g.id], + origin, + xAdvance, + yAdvance: 0, + xOffset: offset.x, + yOffset: offset.y, + cluster: run.clusterStart + idx, + bounds: glyph?.bounds ?? null, + }; + }); + + return { ...run, glyphs, advance: totalAdvance }; + } +} + +/** Resolve a glyph cell to its display advance (handles invisibles, fallbacks). */ +export function resolveAdvance(cell: GlyphCell, font: Font): number { + const raw = font.glyph(cell.glyphName)?.advance ?? 0; + return displayAdvance(raw, cell.glyphName, cell.codepoint); +} + +export function resolveGlyphOffset(cell: GlyphCell, font: Font): Point2D { + if (!isNonSpacingGlyph(cell.glyphName, cell.codepoint)) return { x: 0, y: 0 }; + + const glyph = font.glyph(cell.glyphName); + if (!glyph) return { x: 0, y: 0 }; + + const metrics = font.getMetrics(); + const targetX = 300; + const targetYForAnchorName = (anchorName: string): number => { + switch (anchorName) { + case "top": + return metrics.capHeight ?? metrics.ascender; + case "bottom": + case "ogonek": + return 0; + case "center": + default: + return (metrics.ascender + metrics.descender) / 2; + } + }; + + const attachingAnchor = glyph.anchors.find((anchor) => { + const name = anchor.name ?? ""; + return name.startsWith("_") && name.length > 1; + }); + + if (attachingAnchor) { + const targetName = attachingAnchor.name!.slice(1); + return { + x: targetX - attachingAnchor.x, + y: targetYForAnchorName(targetName) - attachingAnchor.y, + }; + } + + const bounds = glyph.bounds; + if (!bounds) return { x: 0, y: 0 }; + + const centerX = (bounds.min.x + bounds.max.x) / 2; + const centerY = (bounds.min.y + bounds.max.y) / 2; + return { + x: targetX - centerX, + y: (metrics.ascender + metrics.descender) / 2 - centerY, + }; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts similarity index 64% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts rename to apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts index 1fea2661..c2826786 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { glyphCell as glyph, linebreak } from "./types"; +import { glyphCell as glyph, linebreakCell } from "./types"; import { loadTestFont, makeLayout } from "./testUtils"; import { Font } from "@/lib/model/Font"; @@ -34,7 +34,7 @@ describe("TextLayout", () => { // Linebreak cell splits the buffer into two lines. it("splits on linebreak cell into separate lines", () => { - const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + const layout = makeLayout([glyph("A", 65), linebreakCell(), glyph("B", 66)], font); expect(layout.lines).toHaveLength(2); expect(layout.lines[1].y).toBeLessThan(layout.lines[0].y); @@ -42,7 +42,7 @@ describe("TextLayout", () => { // Second-line baseline math: y = origin.y - lineHeight. it("second-line baseline is one lineHeight below first", () => { - const layout = makeLayout([glyph("A", 65), linebreak, glyph("B", 66)], font); + const layout = makeLayout([glyph("A", 65), linebreakCell(), glyph("B", 66)], font); const metrics = font.getMetrics(); const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); @@ -63,4 +63,32 @@ describe("TextLayout", () => { expect(hit).toEqual({ lineIndex: 0, runIndex: 0, cluster: 1, side: "left" }); expect(layout.pointAt(1)?.x).toBe(aAdvance); }); + + it("resolves edit origin by cell id on the current line", () => { + const a = glyph("A", 65); + const b = glyph("B", 66); + const layout = makeLayout([a, b], font); + const aAdvance = font.glyph("A")?.advance ?? 0; + + expect(layout.editOriginForCell(b.id)).toEqual({ x: aAdvance, y: 0 }); + expect(layout.primaryGlyphForCell(b.id)?.cellIds).toEqual([b.id]); + }); + + it("resolves edit origin by cell id after a linebreak", () => { + const b = glyph("B", 66); + const layout = makeLayout([glyph("A", 65), linebreakCell(), b], font); + + expect(layout.editOriginForCell(b.id)).toEqual({ x: 0, y: layout.lines[1].y }); + }); + + it("returns anchors with cell ids rather than cluster-only hits", () => { + const b = glyph("B", 66); + const layout = makeLayout([glyph("A", 65), b], font); + const aAdvance = font.glyph("A")?.advance ?? 0; + + expect(layout.anchorAtPoint("run-1", { x: aAdvance + 1, y: 0 })).toEqual({ + runId: "run-1", + cellId: b.id, + }); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts similarity index 74% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts rename to apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts index 8db8ca12..eb652e1e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/TextLayout.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts @@ -10,13 +10,17 @@ import type { CaretPosition, Cell, FontMetrics, + GlyphAnchor, GlyphCell, Hit, Line, ParagraphSlice, Point2D, + PositionedGlyph, PositionedRun, SegmentedRun, + TextCellId, + TextRunId, } from "./types"; import type { Positioner } from "./Positioner"; import { Font } from "@/lib/model/Font"; @@ -28,6 +32,12 @@ export interface TextLayoutParams { positioner: Positioner; } +interface AssembledLayout { + lines: Line[]; + totalAdvance: number; + bounds: BoundsType | null; +} + export class TextLayout { readonly metrics: FontMetrics; readonly origin: Point2D; @@ -36,24 +46,23 @@ export class TextLayout { /** @knipclassignore — bbox union over positioned glyphs; populated when shapeHitTest lands */ readonly bounds: BoundsType | null; readonly bufferLength: number; + readonly #cells: readonly Cell[]; constructor(params: TextLayoutParams) { const { cells, origin, font, positioner } = params; + this.#cells = cells; this.metrics = font.getMetrics(); this.origin = origin; this.bufferLength = cells.length; // splitParagraphs → segmentRuns → position → assemble - // Outer array = paragraphs (one Line each); inner array = runs in that paragraph. - const positionedParagraphs: PositionedRun[][] = splitParagraphs(cells).map((p) => - segmentRuns(p).map((run) => positioner.position(run, font)), - ); + const paragraphs: PositionedParagraph[] = splitParagraphs(cells).map((p) => ({ + runs: segmentRuns(p).map((run) => positioner.position(run, font)), + clusterStart: p.clusterStart, + clusterEnd: p.clusterStart + p.glyphs.length + 1, + })); - const { lines, totalAdvance, bounds } = assembleLayout( - positionedParagraphs, - origin, - this.metrics, - ); + const { lines, totalAdvance, bounds } = assembleLayout(paragraphs, origin, this.metrics); this.lines = lines; this.totalAdvance = totalAdvance; this.bounds = bounds; @@ -77,11 +86,11 @@ export class TextLayout { const bottom = line.y + line.descent; if (p.y > top + padding || p.y < bottom - padding) continue; - let cursor = this.origin.x; + let runBase = this.origin.x; for (const [runIndex, run] of line.runs.entries()) { for (const g of run.glyphs) { - const left = cursor; - const right = cursor + g.xAdvance; + const left = runBase + g.origin.x; + const right = left + g.xAdvance; if (p.x >= left - padding && p.x < right + padding) { const mid = left + g.xAdvance / 2; return { @@ -91,8 +100,8 @@ export class TextLayout { side: p.x < mid ? "left" : "right", }; } - cursor = right; } + runBase += run.advance; } return null; } @@ -112,19 +121,73 @@ export class TextLayout { pointAt(cluster: number): CaretPosition | null { const lineHeight = this.metrics.ascender - this.metrics.descender + (this.metrics.lineGap ?? 0); for (const line of this.lines) { - let cursor = this.origin.x; + let runBase = this.origin.x; for (const run of line.runs) { for (const g of run.glyphs) { if (g.cluster === cluster) { - return { x: cursor, y: line.y, lineHeight }; + return { x: runBase + g.origin.x, y: line.y + g.origin.y, lineHeight }; } - cursor += g.xAdvance; } + runBase += run.advance; } } return null; } + glyphsForCell(cellId: TextCellId): readonly PositionedGlyph[] { + const glyphs: PositionedGlyph[] = []; + for (const line of this.lines) { + for (const run of line.runs) { + for (const glyph of run.glyphs) { + if (glyph.cellIds.includes(cellId)) glyphs.push(glyph); + } + } + } + return glyphs; + } + + primaryGlyphForCell(cellId: TextCellId): PositionedGlyph | null { + return this.glyphsForCell(cellId)[0] ?? null; + } + + /** + * Resolve stable text-cell identity to the current scene-space glyph edit + * origin. + * + * cellId + * │ + * ▼ + * PositionedGlyph { origin, xOffset/yOffset } + * │ + * ▼ + * scene edit origin + */ + editOriginForCell(cellId: TextCellId): Point2D | null { + for (const line of this.lines) { + let runBase = this.origin.x; + for (const run of line.runs) { + for (const glyph of run.glyphs) { + if (glyph.cellIds.includes(cellId)) { + return { + x: runBase + glyph.origin.x + glyph.xOffset, + y: line.y + glyph.origin.y + glyph.yOffset, + }; + } + } + runBase += run.advance; + } + } + return null; + } + + anchorAtPoint(runId: TextRunId, p: Point2D, padding: number = 0): GlyphAnchor | null { + const hit = this.hitTest(p, padding); + if (!hit) return null; + const cell = this.#cells[hit.cluster]; + if (!cell || cell.kind !== "glyph") return null; + return { runId, cellId: cell.id }; + } + /** @knipclassignore — convenience for caret construction at a cluster */ caretAt(cluster: number): Caret { return Caret.atCluster(this, cluster); @@ -233,18 +296,26 @@ function segmentRuns(paragraph: ParagraphSlice): SegmentedRun[] { * `bounds` returns null for now; full bbox union over positioned glyphs is * a follow-up. No current test requires it. */ +interface PositionedParagraph { + runs: PositionedRun[]; + clusterStart: number; + clusterEnd: number; +} + function assembleLayout( - positionedParagraphs: PositionedRun[][], + paragraphs: PositionedParagraph[], origin: Point2D, metrics: FontMetrics, -): { lines: Line[]; totalAdvance: number; bounds: BoundsType | null } { +): AssembledLayout { const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); - const lines: Line[] = positionedParagraphs.map((runs, i) => ({ - runs, + const lines: Line[] = paragraphs.map((p, i) => ({ + runs: p.runs, y: origin.y - lineHeight * i, ascent: metrics.ascender, descent: metrics.descender, + clusterStart: p.clusterStart, + clusterEnd: p.clusterEnd, })); let totalAdvance = 0; diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts b/apps/desktop/src/renderer/src/lib/text/layout/index.ts similarity index 75% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts rename to apps/desktop/src/renderer/src/lib/text/layout/index.ts index c0a21934..d25cc3d2 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/index.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/index.ts @@ -2,11 +2,12 @@ export { TextLayout } from "./TextLayout"; export type { TextLayoutParams } from "./TextLayout"; export { Caret } from "./Caret"; export { Positioner } from "./Positioner"; -export { glyphCell, linebreak } from "./types"; +export { createTextCellId, glyphCell, linebreakCell } from "./types"; export type { CaretPosition, Cell, Direction, + GlyphAnchor, GlyphCell, Hit, Line, @@ -14,4 +15,6 @@ export type { PositionedGlyph, PositionedRun, SegmentedRun, + TextCellId, + TextRunId, } from "./types"; diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts similarity index 100% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/testUtils.ts rename to apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts b/apps/desktop/src/renderer/src/lib/text/layout/types.ts similarity index 62% rename from apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts rename to apps/desktop/src/renderer/src/lib/text/layout/types.ts index b14d0d30..05c6edcb 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/types.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/types.ts @@ -1,6 +1,22 @@ import type { FontMetrics, Point2D } from "@shift/types"; import type { Bounds } from "@shift/geo"; +export type TextCellId = string; +export type TextRunId = string; + +export interface GlyphAnchor { + runId: TextRunId; + cellId: TextCellId; +} + +let nextCellId = 1; + +export function createTextCellId(): TextCellId { + const id = `cell_${nextCellId}`; + nextCellId += 1; + return id; +} + /** * A single item in a text buffer. Either a glyph (typed character or picked * variant) or a structural line break. Line breaks are NOT glyphs — they @@ -9,6 +25,7 @@ import type { Bounds } from "@shift/geo"; export type Cell = GlyphCell | LineBreak; export interface GlyphCell { + id: TextCellId; kind: "glyph"; glyphName: string; /** Source codepoint when typed via keyboard; null when picked from a glyph UI. */ @@ -16,19 +33,28 @@ export interface GlyphCell { } export interface LineBreak { + id: TextCellId; kind: "linebreak"; } /** Build a glyph cell. `codepoint` is null when the source isn't a typed character. */ -export function glyphCell(glyphName: string, codepoint: number | null = null): GlyphCell { - return { kind: "glyph", glyphName, codepoint }; +export function glyphCell( + glyphName: string, + codepoint: number | null = null, + id: TextCellId = createTextCellId(), +): GlyphCell { + return { id, kind: "glyph", glyphName, codepoint }; } -/** Singleton linebreak cell — structural paragraph separator. */ -export const linebreak: LineBreak = { kind: "linebreak" }; +/** Build a linebreak cell — structural paragraph separator. */ +export function linebreakCell(id: TextCellId = createTextCellId()): LineBreak { + return { id, kind: "linebreak" }; +} export interface PositionedGlyph { glyphName: string; + cellIds: readonly TextCellId[]; + origin: Point2D; xAdvance: number; yAdvance: number; xOffset: number; @@ -62,6 +88,14 @@ export interface Line { y: number; ascent: number; descent: number; + /** First cluster index that belongs to this line (inclusive). */ + clusterStart: number; + /** + * One past the last cluster on this line — `clusterStart + glyphs.length + 1` + * (the +1 covers either the trailing linebreak's cluster or the after-last + * caret position on the final line). + */ + clusterEnd: number; } export interface Hit { diff --git a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts index be9e0f30..c62d86f6 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts @@ -165,7 +165,7 @@ describe("State diagram compliance", () => { let spec: StateDiagram; beforeEach(() => { - editor.startSession("A", 65); + editor.startSession({ glyphName: "A", unicode: 65 }); pen = new Pen(editor); spec = Pen.stateSpec; }); diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts index 1fd8f1d8..f64fe1fe 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts @@ -3,18 +3,24 @@ import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; /** - * Stub: double-click-to-edit-glyph against the new TextRun API. Re-add the - * real implementation in a follow-up — it needs `layout.shapeHitTest` - * (currently still a throw stub) and the composite-component drill-through - * we deferred. Returns false to defer to the regular double-click handler. + * Double-click on a text run cell to enter in-place editing for that glyph. + * + * Resolves the click to a stable text-cell anchor and lets Editor derive the + * active glyph placement from the current layout. Linebreak cells are not editable. */ export class TextRunEdit implements SelectBehavior { onDoubleClick( state: SelectState, - _ctx: ToolContext, - _event: ToolEventOf<"doubleClick">, + ctx: ToolContext, + event: ToolEventOf<"doubleClick">, ): boolean { if (state.type !== "ready" && state.type !== "selected") return false; - return false; + + const run = ctx.editor.textRun; + const anchor = run.anchorAtPoint(event.point, ctx.editor.hitRadius); + if (!anchor) return false; + ctx.editor.setGlyphFocus(anchor); + + return true; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts index 8c8da4a5..7cf66307 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts @@ -3,17 +3,29 @@ import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; /** - * Stub: visual-only hover indicator on text run cells. Re-add real hit-test - * logic against the new TextRun API in a follow-up. Returns false so other - * pointer-move behaviors still run. + * Updates hover indicator on text run cells during pointer movement. + * + * Visual-only: returns false so subsequent pointer-move behaviors still run. + * Uses advance-box hit-test (not shape-precise) — fine for hover highlight. */ export class TextRunHover implements SelectBehavior { onPointerMove( state: SelectState, - _ctx: ToolContext, - _event: ToolEventOf<"pointerMove">, + ctx: ToolContext, + event: ToolEventOf<"pointerMove">, ): boolean { if (state.type !== "ready" && state.type !== "selected") return false; + + const run = ctx.editor.textRun; + const layout = run.$layout.peek(); + if (!layout) { + run.interaction.setHovered(null); + return false; + } + + const hit = layout.hitTest(event.point, ctx.editor.hitRadius); + run.interaction.setHovered(hit?.cluster ?? null); + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts index cb3e4d7d..b53634a9 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -1,14 +1,16 @@ import { BaseTool, type ToolName } from "../core/BaseTool"; +import { TypingBehavior } from "./behaviors/TypingBehavior"; import type { TextBehavior, TextState } from "./types"; import type { CursorType } from "@/types/editor"; -import { TypingBehavior } from "./behaviors/TypingBehaviour"; +import { glyphCell } from "@/lib/text/layout"; -export class Text extends BaseTool { +export class TextTool extends BaseTool { readonly id: ToolName = "text"; readonly behaviors: TextBehavior[] = [new TypingBehavior()]; - override getCursor(_state: TextState): CursorType { - return { type: "text" }; + override getCursor(state: TextState): CursorType { + if (state.type === "typing") return { type: "text" }; + return { type: "default" }; } initialState(): TextState { @@ -16,19 +18,22 @@ export class Text extends BaseTool { } override activate(): void { - const activeName = this.editor.getActiveGlyphName(); - if (!activeName) { + // Run owner = MAIN glyph, not the currently-active editing glyph. + // Double-clicking a slot changes the active glyph (so its outline becomes + // editable in place) but the run still belongs to whoever owned it — + // the main glyph the user opened from the grid. Keying on activeGlyph + // here would silently switch to a fresh per-active-glyph run when the + // user toggles tools mid-slot-edit, wiping the run they were in. + const owner = this.editor.getGlyphHandle(); + if (!owner) { this.state = { type: "typing" }; this.editor.setPreviewMode(true); return; } - const activeUnicode = this.editor.getActiveGlyphUnicode(); - const run = this.editor.textRuns.switchTo(activeName); - run.seed( - { kind: "glyph", glyphName: activeName, codepoint: activeUnicode }, - this.editor.drawOffset.x, - ); + const ownerName = owner.glyphName; + const run = this.editor.textRuns.switchTo(ownerName); + run.seed(glyphCell(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); run.interaction.suspend(); run.setCursorVisible(true); @@ -44,5 +49,3 @@ export class Text extends BaseTool { this.state = { type: "idle" }; } } - -export default Text; diff --git a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts similarity index 63% rename from apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts rename to apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts index eefc8d29..c8b6734a 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts @@ -3,11 +3,8 @@ import type { ToolContext } from "../../core/Behavior"; import type { TextBehavior, TextState } from "../types"; /** - * Minimal typing behavior. Real keyboard input flows through - * `HiddenTextInput.tsx` directly to `editor.textRun.{insert,delete,...}`; - * this behavior exists so the Text tool has a registered behavior slot - * (state-machine compliance) and can intercept Escape via the tool layer - * if needed. + * Escape via the tool layer (most keys go through `HiddenTextInput` while the + * text tool is active). */ export class TypingBehavior implements TextBehavior { onKeyDown(state: TextState, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean { diff --git a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts b/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts deleted file mode 100644 index 81bb5802..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/text/layout/Positioner.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { displayAdvance } from "@/lib/utils/unicode"; -import type { GlyphCell, PositionedRun, SegmentedRun } from "./types"; -import { Font } from "@/lib/model/Font"; - -/** - * No-shape positioner — literal LTR advance walk, `cluster = clusterStart + i`. - * Permanent product mode for editing scripts where the user wants source-order - * glyph display without joining/contextual substitution (e.g. Arabic - * side-by-side editing). - * - * - */ -export class Positioner { - position(run: SegmentedRun, font: Font): PositionedRun { - let totalAdvance = 0; - - const glyphs = run.glyphs.map((g, idx) => { - const glyph = font.glyph(g.glyphName); - const xAdvance = resolveAdvance(g, font); - totalAdvance += xAdvance; - - return { - glyphName: glyph?.name ?? g.glyphName, - xAdvance, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - cluster: run.clusterStart + idx, - bounds: glyph?.bounds ?? null, - }; - }); - - return { ...run, glyphs, advance: totalAdvance }; - } -} - -/** Resolve a glyph cell to its display advance (handles invisibles, fallbacks). */ -export function resolveAdvance(cell: GlyphCell, font: Font): number { - const raw = font.glyph(cell.glyphName)?.advance ?? 0; - return displayAdvance(raw, cell.glyphName, cell.codepoint); -} diff --git a/apps/desktop/src/renderer/src/lib/tools/tools.ts b/apps/desktop/src/renderer/src/lib/tools/tools.ts index fd0984ab..b50d1d3f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/tools.ts +++ b/apps/desktop/src/renderer/src/lib/tools/tools.ts @@ -10,7 +10,7 @@ import { Hand } from "./hand"; import { Pen } from "./pen"; import { Select } from "./select"; import { Shape } from "./shape"; -import TextTool from "./text/Text"; +import { TextTool } from "./text/Text"; export function registerBuiltInTools(editor: Editor): void { editor.registerTool({ diff --git a/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts b/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts index 399aa903..1d46a798 100644 --- a/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts +++ b/apps/desktop/src/renderer/src/lib/utils/unicode.test.ts @@ -41,6 +41,7 @@ describe("cellFromCodepoint", () => { getMappedGlyphName: () => null, }); - expect(cell).toEqual({ kind: "glyph", glyphName: "A", codepoint: 0x41 }); + expect(cell).toMatchObject({ kind: "glyph", glyphName: "A", codepoint: 0x41 }); + expect(cell.id).toEqual(expect.any(String)); }); }); diff --git a/apps/desktop/src/renderer/src/lib/utils/unicode.ts b/apps/desktop/src/renderer/src/lib/utils/unicode.ts index 45fe2687..9e74b914 100644 --- a/apps/desktop/src/renderer/src/lib/utils/unicode.ts +++ b/apps/desktop/src/renderer/src/lib/utils/unicode.ts @@ -1,4 +1,5 @@ -import type { GlyphCell } from "@/lib/tools/text/layout"; +import type { GlyphCell } from "@/lib/text/layout/types"; +import { glyphCell } from "@/lib/text/layout/types"; /** * Convert a Unicode codepoint to its hex representation without prefix (e.g. for URLs). @@ -38,11 +39,7 @@ export function resolveGlyphNameFromUnicode(unicode: number, deps: GlyphNameReso } export function cellFromCodepoint(codepoint: number, deps: GlyphNameResolverDeps): GlyphCell { - return { - kind: "glyph", - glyphName: resolveGlyphNameFromUnicode(codepoint, deps), - codepoint, - }; + return glyphCell(resolveGlyphNameFromUnicode(codepoint, deps), codepoint); } const nonSpacingCache = new Map(); diff --git a/apps/desktop/src/renderer/src/perf/drawing.bench.ts b/apps/desktop/src/renderer/src/perf/drawing.bench.ts index ef94dad8..9e006281 100644 --- a/apps/desktop/src/renderer/src/perf/drawing.bench.ts +++ b/apps/desktop/src/renderer/src/perf/drawing.bench.ts @@ -22,7 +22,7 @@ pm50k.editor.selectTool("select"); describe("pen tool — rapid point placement", () => { bench("place 100 points sequentially", () => { const editor = new TestEditor(); - editor.startSession("pen-bench"); + editor.startSession({ glyphName: "pen-bench" }); editor.selectTool("pen"); for (let i = 0; i < 100; i++) { editor.click(i * 10, i * 5); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 6318eada..33e61e7e 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -3,12 +3,13 @@ * * Usage: * const editor = new TestEditor(); - * editor.startSession("A"); + * editor.startSession(); * editor.selectTool("pen"); * editor.click(100, 200); * expect(editor.pointCount).toBe(1); */ +import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; import type { Point2D, PointId, GlyphSnapshot } from "@shift/types"; import type { Glyph } from "@/lib/model/Glyph"; import { Glyphs } from "@shift/font"; @@ -49,8 +50,9 @@ export class TestEditor extends Editor { return this.#clipboard.buffer; } - startSession(glyphName = "A"): this { - this.open(glyphName); + startSession(handle: GlyphHandle = { glyphName: "A", unicode: 65 }): this { + this.setGlyphHandle(handle); + this.openGlyph(handle); return this; } diff --git a/apps/desktop/src/renderer/src/testing/pointMark.ts b/apps/desktop/src/renderer/src/testing/pointMark.ts index fb2290b6..71f684c1 100644 --- a/apps/desktop/src/renderer/src/testing/pointMark.ts +++ b/apps/desktop/src/renderer/src/testing/pointMark.ts @@ -109,7 +109,7 @@ export interface PointMarkEditor { */ export function createPointMark(scale: PointScale): PointMarkEditor { const editor = new TestEditor(); - editor.startSession("bench"); + editor.startSession({ glyphName: "bench" }); const contours = generateContours(scale); const result = editor.bridge.pasteContours(contours, 0, 0); diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index 6bb5a476..d2295f45 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -28,9 +28,9 @@ export const Landing = () => { const handleNewFont = () => { const editor = getEditor(); const name = editor.font.glyphName(65); - editor.setMainGlyphUnicode(65); - editor.open(name); - editor.setDrawOffsetForGlyph({ x: 0, y: 0 }, name, 65); + const handle = { glyphName: name, unicode: 65 }; + editor.setGlyphHandle(handle); + editor.openGlyph(handle); editor.font.reset(); setFilePath(null); clearDirty(); diff --git a/apps/desktop/src/shared/bridge/FontEngineAPI.ts b/apps/desktop/src/shared/bridge/FontEngineAPI.ts index 44d1e7a8..234d0fe2 100644 --- a/apps/desktop/src/shared/bridge/FontEngineAPI.ts +++ b/apps/desktop/src/shared/bridge/FontEngineAPI.ts @@ -2,11 +2,13 @@ * Derived from napi-rs generated FontEngine class — zero maintenance. * When you add a #[napi] method in Rust and rebuild, it appears here automatically. */ -import type { FontEngine, JsNodeRef, JsNodePositionUpdate } from "shift-node"; +import type { FontEngine, GlyphHandle, JsNodeRef, JsNodePositionUpdate } from "shift-node"; import type { RenderContourSnapshot } from "@shift/types"; export type FontEngineAPI = Omit; +export type { GlyphHandle }; + export type NodeRef = JsNodeRef; export type NodePositionUpdate = JsNodePositionUpdate; diff --git a/crates/shift-node/docs/DOCS.md b/crates/shift-node/docs/DOCS.md index 6e89e5cc..4d1afe53 100644 --- a/crates/shift-node/docs/DOCS.md +++ b/crates/shift-node/docs/DOCS.md @@ -33,7 +33,7 @@ crates/shift-node/ - `SaveFontTask` -- NAPI `Task` impl for async font saving (`save_font_async`) - `JsNodeRef` -- tagged union (`kind` + `id` string) representing a point, anchor, or guideline across the NAPI boundary - `JsNodePositionUpdate` -- pairs a `JsNodeRef` with `(x, y)` for batch position updates -- `JsGlyphRef` -- glyph name + optional unicode for session start +- `GlyphHandle` -- glyph name + optional unicode for session start - `CommandResult` (from shift-core) -- uniform JSON result shape returned by all mutations - `parse_or_err!` -- macro that parses a string ID into a typed ID or returns a `CommandResult::error` @@ -43,18 +43,18 @@ crates/shift-node/ Four internal helpers centralize the mutation-to-JSON pipeline. Each acquires the `EditSession`, runs a closure, builds a `CommandResult`, enriches it with composite contours, and serializes to JSON: -| Helper | Closure signature | Use when | -|---------------------|-------------------------------------------------------|--------------------------------------------| -| `command` | `&mut EditSession -> Vec` | mutation returns affected point IDs | -| `command_simple` | `&mut EditSession -> ()` | mutation has no meaningful return | -| `command_try` | `&mut EditSession -> Result, String>` | mutation can fail with a domain error | -| `command_try_simple`| `&mut EditSession -> Result<(), String>` | fallible mutation, no affected IDs | +| Helper | Closure signature | Use when | +| -------------------- | -------------------------------------------------- | ------------------------------------- | +| `command` | `&mut EditSession -> Vec` | mutation returns affected point IDs | +| `command_simple` | `&mut EditSession -> ()` | mutation has no meaningful return | +| `command_try` | `&mut EditSession -> Result, String>` | mutation can fail with a domain error | +| `command_try_simple` | `&mut EditSession -> Result<(), String>` | fallible mutation, no affected IDs | All four delegate to `with_command_result`, which calls `serialize_enriched_result` to attach resolved composite contours before returning the JSON string. ### Session lifecycle -1. JS calls `start_edit_session(JsGlyphRef)`. +1. JS calls `start_edit_session(GlyphHandle)`. 2. `FontEngine` calls `font.take_glyph()`, removing the glyph from the font store. It picks the most complex layer and creates an `EditSession` over it. 3. Mutations flow through the command helpers above. 4. `end_edit_session` moves the edited layer back into the glyph, puts the glyph back into the font, and rebuilds the `DependencyGraph`. diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index a5de8859..031043f7 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -34,7 +34,7 @@ export declare class FontEngine { */ getGlyphData(glyphName: string): string | null getGlyphVariationData(glyphName: string): string | null - startEditSession(glyphRef: JsGlyphRef): void + startEditSession(glyphRef: GlyphHandle): void endEditSession(): void hasEditSession(): boolean getEditingUnicode(): number | null @@ -71,7 +71,7 @@ export declare class FontEngine { restoreSnapshot(snapshotJson: string): boolean } -export interface JsGlyphRef { +export interface GlyphHandle { glyphName: string unicode?: number } diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index efdcbf71..317a32f5 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -87,7 +87,7 @@ pub struct SaveFontTask { } #[napi(object)] -pub struct JsGlyphRef { +pub struct GlyphHandle { #[napi(js_name = "glyphName")] pub glyph_name: String, pub unicode: Option, @@ -619,7 +619,7 @@ impl FontEngine { } #[napi] - pub fn start_edit_session(&mut self, glyph_ref: JsGlyphRef) -> Result<()> { + pub fn start_edit_session(&mut self, glyph_ref: GlyphHandle) -> Result<()> { self.start_edit_session_for_name(&glyph_ref.glyph_name, glyph_ref.unicode) } @@ -1091,7 +1091,7 @@ mod tests { let mut engine = FontEngine::new(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "A".to_string(), unicode: Some(65), }) @@ -1110,7 +1110,7 @@ mod tests { let mut engine = FontEngine::new(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "A".to_string(), unicode: Some(65), }) @@ -1127,12 +1127,12 @@ mod tests { let mut engine = FontEngine::new(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "A".to_string(), unicode: Some(65), }) .unwrap(); - let result = engine.start_edit_session(JsGlyphRef { + let result = engine.start_edit_session(GlyphHandle { glyph_name: "B".to_string(), unicode: Some(66), }); @@ -1144,7 +1144,7 @@ mod tests { fn test_add_contour() { let mut engine = FontEngine::new(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "A".to_string(), unicode: Some(65), }) @@ -1208,7 +1208,7 @@ mod tests { let mut engine = FontEngine::new(); engine.load_font(path_str.to_string()).unwrap(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "A".to_string(), unicode: Some(65), }) @@ -1253,7 +1253,7 @@ mod tests { let mut engine = FontEngine::new(); engine.load_font(path_str.to_string()).unwrap(); engine - .start_edit_session(JsGlyphRef { + .start_edit_session(GlyphHandle { glyph_name: "Aacute".to_string(), unicode: Some(0x00C1), }) diff --git a/packages/validation/src/persistence.test.ts b/packages/validation/src/persistence.test.ts index ccf62c83..ca41f858 100644 --- a/packages/validation/src/persistence.test.ts +++ b/packages/validation/src/persistence.test.ts @@ -38,8 +38,8 @@ describe("persistence schemas", () => { "65": { buffer: { cells: [ - { kind: "glyph", glyphName: "A", codepoint: 65 }, - { kind: "glyph", glyphName: "B", codepoint: 66 }, + { id: "a1", kind: "glyph", glyphName: "A", codepoint: 65 }, + { id: "b1", kind: "glyph", glyphName: "B", codepoint: 66 }, ], cursor: 2, anchor: 2, @@ -63,7 +63,7 @@ describe("persistence schemas", () => { runsByGlyph: { "65": { buffer: { - cells: [{ kind: "glyph", glyphName: "A", codepoint: 65 }], + cells: [{ id: "a1", kind: "glyph", glyphName: "A", codepoint: 65 }], cursor: "1", anchor: 0, originX: 0, diff --git a/packages/validation/src/persistence.ts b/packages/validation/src/persistence.ts index 0276f311..1ca49183 100644 --- a/packages/validation/src/persistence.ts +++ b/packages/validation/src/persistence.ts @@ -1,12 +1,14 @@ import { z } from "zod"; export const GlyphCellSchema = z.object({ + id: z.string().min(1), kind: z.literal("glyph"), glyphName: z.string().min(1), codepoint: z.number().int().nonnegative().nullable(), }); export const LineBreakSchema = z.object({ + id: z.string().min(1), kind: z.literal("linebreak"), }); From 6e4656b8c0faff65d02954045b4294e15fff1161 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Apr 2026 20:29:12 +0100 Subject: [PATCH 05/13] test: cover Editor shouldRenderGlyph + glyph focus placement --- .../renderer/src/lib/editor/Editor.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/desktop/src/renderer/src/lib/editor/Editor.test.ts diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts new file mode 100644 index 00000000..7eaa100a --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TestEditor } from "@/testing/TestEditor"; +import { glyphCell, linebreakCell } from "@/lib/text/layout"; + +describe("Editor", () => { + let editor: TestEditor; + + beforeEach(() => { + editor = new TestEditor(); + editor.startSession(); + }); + + // shouldRenderGlyph rule: + // No active text-run activity (buffer empty AND cursor not visible) + // → render the glyph normally. (grid → canvas open path) + // Active text run (typed something or Text tool active) + // → render the glyph only when in-place editing a slot. + describe("shouldRenderGlyph", () => { + it("renders the glyph in initial state (no run, no cursor visible)", () => { + expect(editor.textRun.buffer.cells).toHaveLength(0); + expect(editor.textRun.cursorVisible).toBe(false); + expect(editor.shouldRenderGlyph()).toBe(true); + }); + + it("does not render the glyph once the run has cells and no slot edit", () => { + editor.textRun.buffer.insert(glyphCell("B", 66)); + expect(editor.shouldRenderGlyph()).toBe(false); + }); + + it("renders the glyph again when in-place editing a slot", () => { + const cell = glyphCell("B", 66); + editor.textRun.buffer.insert(cell); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: cell.id }); + expect(editor.shouldRenderGlyph()).toBe(true); + }); + + it("does not render the glyph with empty buffer but cursor visible (Text tool active)", () => { + editor.textRun.setCursorVisible(true); + expect(editor.shouldRenderGlyph()).toBe(false); + }); + }); + + // Regression: the text run is owned by the *main* glyph (the one opened + // from the grid), not by the *active* editing glyph. Double-clicking a + // slot to drill into editing switches the active glyph but the run owner + // stays put. So switching tools (Select↔Text) mid-slot-edit must keep + // the run intact. + describe("text-run owner = main glyph (not active editing glyph)", () => { + it("keeps the run's cells when switching back to Text after a slot drill-in", () => { + // A is the main glyph (the one the user "opened from the grid"). + const ownerKey = editor.font.glyphName(65); + editor.setGlyphHandle({ glyphName: ownerKey, unicode: 65 }); + + editor.selectTool("text"); + editor.textRun.insert(glyphCell("B", 66)); + expect(editor.textRun.buffer.cells).toHaveLength(2); + + // Drill into slot 1 (the B): mirrors what TextRunEdit does on dblclick. + editor.selectTool("select"); + const bCell = editor.textRun.buffer.cells[1]; + expect(bCell.kind).toBe("glyph"); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: bCell.id }); + expect(editor.getActiveGlyphName()).toBe("B"); + // Main glyph (run owner) hasn't moved. + expect(editor.getGlyphHandle()!.glyphName).toBe(ownerKey); + + // Toggle back to Text. The run should still be the A-keyed run, with + // its cells preserved — not a fresh B-keyed run. + editor.selectTool("text"); + + expect(editor.textRun.buffer.cells).toHaveLength(2); + expect(editor.textRun.buffer.cells[0]).toMatchObject({ + kind: "glyph", + glyphName: ownerKey, + codepoint: 65, + }); + expect(editor.textRun.buffer.cells[1]).toBe(bCell); + }); + }); + + describe("glyph focus placement", () => { + it("recomputes drawOffset from the focused cell after inserting a linebreak before it", () => { + const ownerKey = editor.font.glyphName(65); + editor.setGlyphHandle({ glyphName: ownerKey, unicode: 65 }); + editor.selectTool("text"); + const b = glyphCell("B", 66); + editor.textRun.insert(b); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: b.id }); + const firstLineOrigin = editor.glyphPlacement?.focused.editOrigin; + + editor.textRun.buffer.placeCaret(1); + editor.textRun.insert(linebreakCell()); + editor.selectTool("select"); + + const secondLineOrigin = editor.textRun.$layout.peek()?.editOriginForCell(b.id); + expect(editor.focusedGlyph?.anchor.cellId).toBe(b.id); + expect(editor.focusedGlyph?.glyph.glyphName).toBe("B"); + expect(secondLineOrigin?.y).toBe(editor.textRun.$layout.peek()?.lines[1].y); + expect(editor.glyphPlacement?.focused.editOrigin).toEqual(secondLineOrigin); + expect(editor.drawOffset).toEqual(secondLineOrigin); + expect(editor.drawOffset).not.toEqual(firstLineOrigin); + }); + + it("clears derived placement when the focused cell is deleted", () => { + editor.selectTool("text"); + const b = glyphCell("B", 66); + editor.textRun.insert(b); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: b.id }); + + editor.textRun.buffer.placeCaret(2); + editor.textRun.delete(); + + expect(editor.focusedGlyph).toBeNull(); + expect(editor.glyphPlacement).toBeNull(); + expect(editor.drawOffset).toEqual({ x: 0, y: 0 }); + }); + + it("opens direct glyphs through the implicit editor run", () => { + editor.openGlyph({ glyphName: "S", unicode: 83 }); + + expect(editor.focusedGlyph?.glyph.glyphName).toBe("S"); + expect(editor.textRuns.resolveAnchor(editor.focusedGlyph!.anchor)).toEqual( + editor.focusedGlyph, + ); + expect(editor.drawOffset).toEqual(editor.glyphPlacement?.focused.editOrigin); + }); + }); +}); From 3b35d48c1166e2e92d3cb56f90e007154a013e00 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 5 May 2026 08:55:55 +0100 Subject: [PATCH 06/13] refactor: split Rust bridge and edit crates Rename the core editing crate to shift-edit, move native bridge ownership from shift-node to shift-bridge, and introduce shift-wire as the DTO boundary shared by bridge adapters. Font loading moves into shift-backends, while shift-edit owns edit session state, glyph structure restoration, interpolation, and active edit mutation semantics. The bridge now uses typed errors and active edit state instead of the old edit-session/snapshot paths. This is a migration checkpoint: Rust workspace tests exclude the NAPI cdylib, while the native bridge is verified through the NAPI debug build. Renderer call sites are still being moved onto the new API in later commits. Verified with cargo test --workspace --exclude shift-bridge, cargo clippy --workspace --all-targets --exclude shift-bridge -- -D warnings, and pnpm --filter shift-bridge run build:debug. --- .github/workflows/ci.yml | 8 +- .github/workflows/perf.yml | 8 +- .pre-commit-config.yaml | 12 +- Cargo.lock | 132 +- Cargo.toml | 7 +- crates/shift-backends/Cargo.toml | 7 +- crates/shift-backends/src/binary/mod.rs | 3 + .../src/binary/reader.rs} | 3 +- crates/shift-backends/src/errors.rs | 28 + .../src/font_loader.rs | 62 +- crates/shift-backends/src/glyphs/mod.rs | 6 +- crates/shift-backends/src/glyphs/reader.rs | 8 +- crates/shift-backends/src/lib.rs | 6 +- crates/shift-backends/src/traits.rs | 73 +- crates/shift-backends/src/ufo/reader.rs | 8 +- crates/shift-backends/src/ufo/writer.rs | 20 +- crates/shift-backends/tests/loading.rs | 230 +++ crates/shift-backends/tests/round_trip.rs | 3 + crates/shift-backends/tests/round_trip/ufo.rs | 203 +++ .../.github/workflows/CI.yml | 0 .../{shift-node => shift-bridge}/.gitignore | 0 .../{shift-node => shift-bridge}/.npmignore | 0 .../{shift-node => shift-bridge}/.yarnrc.yml | 0 crates/shift-bridge/Cargo.toml | 24 + crates/shift-bridge/__test__/index.spec.mjs | 164 +++ crates/shift-bridge/build.rs | 11 + crates/shift-bridge/docs/DOCS.md | 86 ++ crates/shift-bridge/dts-header.d.ts | 11 + crates/shift-bridge/index.d.ts | 191 +++ crates/{shift-node => shift-bridge}/index.js | 163 +-- .../{shift-node => shift-bridge}/package.json | 13 +- .../{shift-node => shift-bridge}/rustfmt.toml | 0 crates/shift-bridge/src/bridge.rs | 1149 +++++++++++++++ crates/shift-bridge/src/errors.rs | 54 + crates/shift-bridge/src/input.rs | 44 + crates/shift-bridge/src/lib.rs | 3 + .../vitest.config.ts | 0 crates/shift-core/docs/DOCS.md | 118 -- crates/shift-core/src/constants.rs | 2 - crates/shift-core/src/edit_session.rs | 1051 -------------- crates/shift-core/src/interpolation.rs | 191 --- crates/shift-core/src/snapshot.rs | 381 ----- crates/shift-core/tests/font_loading.rs | 668 --------- .../shift-core/tests/interpolation_parity.rs | 206 --- crates/shift-core/tests/round_trip.rs | 922 ------------ crates/{shift-core => shift-edit}/Cargo.toml | 15 +- crates/shift-edit/docs/DOCS.md | 82 ++ .../src/composite.rs | 22 +- .../{shift-core => shift-edit}/src/curve.rs | 0 .../src/dependency_graph.rs | 0 crates/shift-edit/src/edit_session.rs | 1180 +++++++++++++++ crates/shift-edit/src/error.rs | 42 + crates/shift-edit/src/interpolation.rs | 216 +++ crates/{shift-core => shift-edit}/src/lib.rs | 16 +- crates/shift-edit/src/state.rs | 333 +++++ crates/{shift-core => shift-edit}/src/vec2.rs | 0 crates/shift-ir/Cargo.toml | 3 +- crates/shift-ir/docs/DOCS.md | 24 +- crates/shift-ir/src/axis.rs | 7 +- crates/shift-ir/src/component.rs | 33 +- crates/shift-ir/src/font.rs | 336 ++++- crates/shift-ir/src/glyph.rs | 66 +- crates/shift-ir/src/glyph_name.rs | 103 ++ crates/shift-ir/src/kerning.rs | 47 +- crates/shift-ir/src/lib.rs | 4 +- crates/shift-ir/src/metrics.rs | 4 +- crates/shift-ir/src/source.rs | 6 +- crates/shift-node/Cargo.toml | 18 - .../__test__/font_integration.spec.mjs | 662 --------- crates/shift-node/__test__/index.spec.mjs | 33 - crates/shift-node/build.rs | 5 - crates/shift-node/docs/DOCS.md | 117 -- crates/shift-node/dts-header.d.ts | 1 - crates/shift-node/index.d.ts | 90 -- crates/shift-node/src/font_engine.rs | 1269 ----------------- crates/shift-node/src/lib.rs | 1 - crates/shift-wire/Cargo.toml | 22 + crates/shift-wire/src/bridges/mod.rs | 2 + crates/shift-wire/src/bridges/napi/mod.rs | 489 +++++++ crates/shift-wire/src/lib.rs | 494 +++++++ scripts/check-napi-dead-methods.sh | 12 +- 81 files changed, 5881 insertions(+), 6152 deletions(-) create mode 100644 crates/shift-backends/src/binary/mod.rs rename crates/{shift-core/src/binary.rs => shift-backends/src/binary/reader.rs} (99%) create mode 100644 crates/shift-backends/src/errors.rs rename crates/{shift-core => shift-backends}/src/font_loader.rs (70%) create mode 100644 crates/shift-backends/tests/loading.rs create mode 100644 crates/shift-backends/tests/round_trip.rs create mode 100644 crates/shift-backends/tests/round_trip/ufo.rs rename crates/{shift-node => shift-bridge}/.github/workflows/CI.yml (100%) rename crates/{shift-node => shift-bridge}/.gitignore (100%) rename crates/{shift-node => shift-bridge}/.npmignore (100%) rename crates/{shift-node => shift-bridge}/.yarnrc.yml (100%) create mode 100644 crates/shift-bridge/Cargo.toml create mode 100644 crates/shift-bridge/__test__/index.spec.mjs create mode 100644 crates/shift-bridge/build.rs create mode 100644 crates/shift-bridge/docs/DOCS.md create mode 100644 crates/shift-bridge/dts-header.d.ts create mode 100644 crates/shift-bridge/index.d.ts rename crates/{shift-node => shift-bridge}/index.js (74%) rename crates/{shift-node => shift-bridge}/package.json (80%) rename crates/{shift-node => shift-bridge}/rustfmt.toml (100%) create mode 100644 crates/shift-bridge/src/bridge.rs create mode 100644 crates/shift-bridge/src/errors.rs create mode 100644 crates/shift-bridge/src/input.rs create mode 100644 crates/shift-bridge/src/lib.rs rename crates/{shift-node => shift-bridge}/vitest.config.ts (100%) delete mode 100644 crates/shift-core/docs/DOCS.md delete mode 100644 crates/shift-core/src/constants.rs delete mode 100644 crates/shift-core/src/edit_session.rs delete mode 100644 crates/shift-core/src/interpolation.rs delete mode 100644 crates/shift-core/src/snapshot.rs delete mode 100644 crates/shift-core/tests/font_loading.rs delete mode 100644 crates/shift-core/tests/interpolation_parity.rs delete mode 100644 crates/shift-core/tests/round_trip.rs rename crates/{shift-core => shift-edit}/Cargo.toml (54%) create mode 100644 crates/shift-edit/docs/DOCS.md rename crates/{shift-core => shift-edit}/src/composite.rs (97%) rename crates/{shift-core => shift-edit}/src/curve.rs (100%) rename crates/{shift-core => shift-edit}/src/dependency_graph.rs (100%) create mode 100644 crates/shift-edit/src/edit_session.rs create mode 100644 crates/shift-edit/src/error.rs create mode 100644 crates/shift-edit/src/interpolation.rs rename crates/{shift-core => shift-edit}/src/lib.rs (57%) create mode 100644 crates/shift-edit/src/state.rs rename crates/{shift-core => shift-edit}/src/vec2.rs (100%) create mode 100644 crates/shift-ir/src/glyph_name.rs delete mode 100644 crates/shift-node/Cargo.toml delete mode 100644 crates/shift-node/__test__/font_integration.spec.mjs delete mode 100644 crates/shift-node/__test__/index.spec.mjs delete mode 100644 crates/shift-node/build.rs delete mode 100644 crates/shift-node/docs/DOCS.md delete mode 100644 crates/shift-node/dts-header.d.ts delete mode 100644 crates/shift-node/index.d.ts delete mode 100644 crates/shift-node/src/font_engine.rs delete mode 100644 crates/shift-node/src/lib.rs create mode 100644 crates/shift-wire/Cargo.toml create mode 100644 crates/shift-wire/src/bridges/mod.rs create mode 100644 crates/shift-wire/src/bridges/napi/mod.rs create mode 100644 crates/shift-wire/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0ec7dc..55e48648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,7 +171,7 @@ jobs: id: native-cache uses: actions/cache@v4 with: - path: crates/shift-node/*.node + path: crates/shift-bridge/*.node key: native-module-${{ runner.os }}-${{ hashFiles('crates/**', 'Cargo.lock') }} - name: Build native module @@ -262,7 +262,7 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Run tests - run: cargo test --workspace --exclude shift-node + run: cargo test --workspace --exclude shift-bridge integration: name: Integration Tests @@ -308,7 +308,7 @@ jobs: - name: Build native module run: pnpm build:native - - name: Run shift-node tests + - name: Run shift-bridge tests run: pnpm test:native # --------------------------------------------------------------------------- @@ -368,7 +368,7 @@ jobs: id: native-cache uses: actions/cache@v4 with: - path: crates/shift-node/*.node + path: crates/shift-bridge/*.node key: native-module-${{ runner.os }}-${{ hashFiles('crates/**', 'Cargo.lock') }} - name: Build native module diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index c54f6e7c..56ede728 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -28,8 +28,8 @@ on: - "apps/desktop/src/renderer/src/bridge/NativeBridge.ts" - "apps/desktop/src/renderer/src/lib/commands/**" - "apps/desktop/src/renderer/src/perf/**" - - "crates/shift-core/src/**" - - "crates/shift-node/src/**" + - "crates/shift-edit/src/**" + - "crates/shift-bridge/src/**" permissions: {} @@ -79,7 +79,7 @@ jobs: id: native-cache uses: actions/cache@v4 with: - path: crates/shift-node/*.node + path: crates/shift-bridge/*.node key: native-module-${{ runner.os }}-${{ hashFiles('crates/**', 'Cargo.lock') }} - name: Build native module @@ -142,7 +142,7 @@ jobs: id: native-cache uses: actions/cache@v4 with: - path: crates/shift-node/*.node + path: crates/shift-bridge/*.node key: native-module-${{ runner.os }}-${{ hashFiles('crates/**', 'Cargo.lock') }} - name: Build native module diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15015e77..db806361 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ repos: # Standard file hygiene - # Generated files (ts-rs output, NAPI .d.ts, parity fixtures) are excluded + # Generated files (bridge DTO typegen, NAPI .d.ts, parity fixtures) are excluded # because their format is owned by the generator. Re-formatting them here - # creates a loop with the cargo-test hook that regenerates them. + # creates churn with the hooks that regenerate them. - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - exclude: '^(packages/types/src/generated/|packages/types/__fixtures__/|crates/shift-node/index\.(d\.ts|js))' + exclude: '^(packages/types/src/bridge/generated\.ts|packages/types/__fixtures__/|crates/shift-bridge/index\.(d\.ts|js))' - id: end-of-file-fixer - exclude: '^(packages/types/src/generated/|packages/types/__fixtures__/|crates/shift-node/index\.(d\.ts|js))' + exclude: '^(packages/types/src/bridge/generated\.ts|packages/types/__fixtures__/|crates/shift-bridge/index\.(d\.ts|js))' - id: check-yaml - id: check-added-large-files - id: check-merge-conflict @@ -41,7 +41,7 @@ repos: - id: cargo-check-tests name: cargo check (including tests) - entry: bash -c 'cargo check --package shift-node --tests' + entry: cargo check --workspace --tests --exclude shift-bridge language: system types: [rust] pass_filenames: false @@ -62,7 +62,7 @@ repos: - id: cargo-test name: cargo test - entry: bash -c 'cargo test --workspace --exclude shift-node && npx tsx scripts/patch-generated-types.ts && git add packages/types/src/generated/*.ts' + entry: cargo test --workspace --exclude shift-bridge language: system types: [rust] pass_filenames: false diff --git a/Cargo.lock b/Cargo.lock index 2541747c..2fee5bfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -355,19 +355,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "400a21f1014a968ec518c7ccdf9b4a4ed0cac8c56ccb6d604f8b91f00110501e" [[package]] name = "darling" @@ -455,21 +445,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "either" version = "1.15.0" @@ -665,7 +640,7 @@ dependencies = [ "ordered-float 5.3.0", "serde", "smol_str 0.3.6", - "thiserror 2.0.12", + "thiserror 2.0.18", "write-fonts 0.44.1", ] @@ -1142,9 +1117,9 @@ checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" [[package]] name = "napi" -version = "3.8.4" +version = "3.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb7848c221fb7bb789e02f01875287ebb1e078b92a6566a34de01ef8806e7c2b" +checksum = "8e55037284865448ecf329baa86a4d05401f647ebde99f5747b640d32c2c5226" dependencies = [ "bitflags", "ctor", @@ -1163,9 +1138,9 @@ checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" [[package]] name = "napi-derive" -version = "3.5.3" +version = "3.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60867ff9a6f76e82350e0c3420cb0736f5866091b61d7d8a024baa54b0ec17dd" +checksum = "a4ba740fe4c9524d86fd90798fd8ccdb23402b3eef7e7c30897a8a369b529fcf" dependencies = [ "convert_case", "ctor", @@ -1177,9 +1152,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.0.2" +version = "5.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0864cf6a82e2cfb69067374b64c9253d7e910e5b34db833ed7495dda56ccb18" +checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf" dependencies = [ "convert_case", "proc-macro2", @@ -1217,7 +1192,7 @@ dependencies = [ "serde", "serde_derive", "serde_repr", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -1234,7 +1209,7 @@ dependencies = [ "serde", "serde_derive", "serde_repr", - "thiserror 2.0.12", + "thiserror 2.0.18", "uuid", ] @@ -1661,10 +1636,28 @@ dependencies = [ "plist", "shift-ir", "skrifa", + "tempfile", + "thiserror 2.0.18", ] [[package]] -name = "shift-core" +name = "shift-bridge" +version = "0.0.1" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", + "shift-backends", + "shift-edit", + "shift-ir", + "shift-wire", + "thiserror 2.0.18", +] + +[[package]] +name = "shift-edit" version = "0.0.0" dependencies = [ "bitflags", @@ -1675,9 +1668,9 @@ dependencies = [ "serde_json", "shift-backends", "shift-ir", + "shift-wire", "skrifa", - "tempfile", - "ts-rs", + "thiserror 2.0.18", ] [[package]] @@ -1689,19 +1682,16 @@ dependencies = [ "kurbo 0.13.0", "linesweeper", "serde", - "ts-rs", ] [[package]] -name = "shift-node" -version = "0.0.1" +name = "shift-wire" +version = "0.0.0" dependencies = [ "napi", - "napi-build", "napi-derive", "serde", - "serde_json", - "shift-core", + "shift-ir", ] [[package]] @@ -1801,15 +1791,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1821,11 +1802,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -1841,9 +1822,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1903,28 +1884,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ts-rs" -version = "11.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" -dependencies = [ - "thiserror 2.0.12", - "ts-rs-macros", -] - -[[package]] -name = "ts-rs-macros" -version = "11.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "termcolor", -] - [[package]] name = "ufo2fontir" version = "0.2.0" @@ -2106,15 +2065,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index b7156032..eee6f173 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,8 @@ members = ["crates/*"] [workspace.dependencies] -shift-core = { path = "crates/shift-core" } -shift-node = { path = "crates/shift-node" } +shift-wire = { path = "crates/shift-wire" } +shift-backends = { path = "crates/shift-backends" } +shift-bridge = { path = "crates/shift-bridge" } +shift-edit = { path = "crates/shift-edit" } +shift-ir = { path = "crates/shift-ir" } diff --git a/crates/shift-backends/Cargo.toml b/crates/shift-backends/Cargo.toml index 24c6b3c0..6d89885b 100644 --- a/crates/shift-backends/Cargo.toml +++ b/crates/shift-backends/Cargo.toml @@ -9,9 +9,14 @@ license = "MIT OR Apache-2.0" crate-type = ["rlib"] [dependencies] -shift-ir = { path = "../shift-ir" } +shift-ir = { workspace = true } + norad = "0.16.0" skrifa = "0.32.0" fontc = "0.2.0" glyphs-reader = "0.2.0" plist = "1" +thiserror = "2.0.18" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/shift-backends/src/binary/mod.rs b/crates/shift-backends/src/binary/mod.rs new file mode 100644 index 00000000..b89297cc --- /dev/null +++ b/crates/shift-backends/src/binary/mod.rs @@ -0,0 +1,3 @@ +pub mod reader; + +pub use reader::BytesFontAdaptor; diff --git a/crates/shift-core/src/binary.rs b/crates/shift-backends/src/binary/reader.rs similarity index 99% rename from crates/shift-core/src/binary.rs rename to crates/shift-backends/src/binary/reader.rs index a2585c88..e6aab950 100644 --- a/crates/shift-core/src/binary.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -2,9 +2,8 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use crate::font_loader::FontAdaptor; -use crate::{Contour, Font, GlyphLayer, PointType}; use fontc::JobTimer; -use shift_ir::Glyph; +use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; use skrifa::{ outline::{DrawSettings, OutlinePen}, prelude::{LocationRef, Size}, diff --git a/crates/shift-backends/src/errors.rs b/crates/shift-backends/src/errors.rs new file mode 100644 index 00000000..77bac5eb --- /dev/null +++ b/crates/shift-backends/src/errors.rs @@ -0,0 +1,28 @@ +#[derive(Debug, thiserror::Error)] +pub enum BackendError { + #[error("file has no extension")] + MissingExtension, + + #[error("invalid UTF-8 in path")] + InvalidPathUtf8, + + #[error("invalid UTF-8 in extension")] + InvalidExtensionUtf8, + + #[error("unsupported font format: {0}")] + UnsupportedFormat(String), + + #[error("unsupported font format for writing: {0}")] + UnsupportedWriteFormat(String), + + #[error("font format adaptor is not registered: {0}")] + MissingAdaptor(&'static str), + + #[error("failed to load font: {0}")] + Load(String), + + #[error("failed to save font: {0}")] + Save(String), +} + +pub type BackendResult = Result; diff --git a/crates/shift-core/src/font_loader.rs b/crates/shift-backends/src/font_loader.rs similarity index 70% rename from crates/shift-core/src/font_loader.rs rename to crates/shift-backends/src/font_loader.rs index f618efc3..0fadaa9e 100644 --- a/crates/shift-core/src/font_loader.rs +++ b/crates/shift-backends/src/font_loader.rs @@ -1,13 +1,16 @@ use std::collections::HashMap; use std::path::Path; -use crate::binary::BytesFontAdaptor; -use shift_backends::designspace::DesignspaceReader; -use shift_backends::glyphs::GlyphsReader; -use shift_backends::ufo::UfoReader; -use shift_backends::FontReader; use shift_ir::Font; +use crate::designspace::DesignspaceReader; +use crate::errors::{BackendError, BackendResult}; +use crate::glyphs::GlyphsReader; +use crate::traits::{FontReader, FontWriter}; +use crate::ufo::{UfoReader, UfoWriter}; + +use crate::binary::BytesFontAdaptor; + #[derive(Hash, Eq, PartialEq)] pub enum FontFormat { Ufo, @@ -17,6 +20,18 @@ pub enum FontFormat { Otf, } +impl FontFormat { + fn name(&self) -> &'static str { + match self { + FontFormat::Ufo => "ufo", + FontFormat::Glyphs => "glyphs", + FontFormat::Designspace => "designspace", + FontFormat::Ttf => "ttf", + FontFormat::Otf => "otf", + } + } +} + pub trait FontAdaptor { fn read_font(&self, path: &str) -> Result; fn write_font(&self, font: &Font, path: &str) -> Result<(), String>; @@ -32,8 +47,6 @@ impl FontAdaptor for UfoFontAdaptor { } fn write_font(&self, font: &Font, path: &str) -> Result<(), String> { - use shift_backends::ufo::UfoWriter; - use shift_backends::FontWriter; UfoWriter::new().save(font, path) } } @@ -68,7 +81,7 @@ impl Default for FontLoader { } } -fn format_from_extension(ext: &str) -> Result { +fn format_from_extension(ext: &str) -> BackendResult { match ext.to_ascii_lowercase().as_str() { "ufo" => Ok(FontFormat::Ufo), "glyphs" => Ok(FontFormat::Glyphs), @@ -76,15 +89,15 @@ fn format_from_extension(ext: &str) -> Result { "designspace" => Ok(FontFormat::Designspace), "ttf" => Ok(FontFormat::Ttf), "otf" => Ok(FontFormat::Otf), - _ => Err(format!("Unsupported font format: {ext}")), + _ => Err(BackendError::UnsupportedFormat(ext.to_string())), } } -fn extension_from_path(path: &Path) -> Result<&str, String> { +fn extension_from_path(path: &Path) -> BackendResult<&str> { path.extension() - .ok_or_else(|| "File has no extension".to_string())? + .ok_or(BackendError::MissingExtension)? .to_str() - .ok_or_else(|| "Invalid UTF-8 in extension".to_string()) + .ok_or(BackendError::InvalidExtensionUtf8) } impl FontLoader { @@ -103,29 +116,34 @@ impl FontLoader { self.adaptors.keys().collect() } - pub fn read_font(&self, path: &str) -> Result { + pub fn read_font(&self, path: &str) -> BackendResult { let path = Path::new(path); let ext = extension_from_path(path)?; let format = format_from_extension(ext)?; - let adaptor = self.adaptors.get(&format).expect("all formats registered"); - adaptor.read_font( - path.to_str() - .ok_or_else(|| "Invalid UTF-8 in path".to_string())?, - ) + let adaptor = self + .adaptors + .get(&format) + .ok_or_else(|| BackendError::MissingAdaptor(format.name()))?; + let path = path.to_str().ok_or(BackendError::InvalidPathUtf8)?; + adaptor.read_font(path).map_err(BackendError::Load) } - pub fn write_font(&self, font: &Font, path: &str) -> Result<(), String> { + pub fn write_font(&self, font: &Font, path: &str) -> BackendResult<()> { let path = Path::new(path); let ext = extension_from_path(path)?; let format = format_from_extension(ext)?; match format { FontFormat::Ufo => {} - _ => return Err(format!("Unsupported font format for writing: {ext}")), + _ => return Err(BackendError::UnsupportedWriteFormat(ext.to_string())), } - let adaptor = self.adaptors.get(&format).expect("all formats registered"); - adaptor.write_font(font, path.to_str().unwrap()) + let adaptor = self + .adaptors + .get(&format) + .ok_or_else(|| BackendError::MissingAdaptor(format.name()))?; + let path = path.to_str().ok_or(BackendError::InvalidPathUtf8)?; + adaptor.write_font(font, path).map_err(BackendError::Save) } } diff --git a/crates/shift-backends/src/glyphs/mod.rs b/crates/shift-backends/src/glyphs/mod.rs index 087050f4..40875f92 100644 --- a/crates/shift-backends/src/glyphs/mod.rs +++ b/crates/shift-backends/src/glyphs/mod.rs @@ -45,11 +45,7 @@ mod tests { .expect("Homenaje should include features"); assert!(fea.contains("feature frac")); - assert_eq!( - font.kerning() - .get_kerning(&"A".to_string(), &"V".to_string()), - Some(-55.0) - ); + assert_eq!(font.kerning().get_kerning("A", "V"), Some(-55.0)); let aacute = font.glyph("Aacute").expect("Aacute should exist"); let layer = aacute diff --git a/crates/shift-backends/src/glyphs/reader.rs b/crates/shift-backends/src/glyphs/reader.rs index b9f0032b..996b1741 100644 --- a/crates/shift-backends/src/glyphs/reader.rs +++ b/crates/shift-backends/src/glyphs/reader.rs @@ -58,7 +58,7 @@ impl GlyphsReader { .get(&group_name) .cloned() .unwrap_or_default(); - members.push(glyph.name.to_string()); + members.push(glyph.name.to_string().into()); members.sort(); members.dedup(); kerning.set_group1(group_name, members); @@ -71,7 +71,7 @@ impl GlyphsReader { .get(&group_name) .cloned() .unwrap_or_default(); - members.push(glyph.name.to_string()); + members.push(glyph.name.to_string().into()); members.sort(); members.dedup(); kerning.set_group2(group_name, members); @@ -94,7 +94,7 @@ impl GlyphsReader { { KerningSide::Group(format!("{UFO_SIDE1_PREFIX}{group}")) } else { - KerningSide::Glyph(first.clone()) + KerningSide::Glyph(first.clone().into()) }; let second_side = if let Some(group) = second @@ -103,7 +103,7 @@ impl GlyphsReader { { KerningSide::Group(format!("{UFO_SIDE2_PREFIX}{group}")) } else { - KerningSide::Glyph(second.clone()) + KerningSide::Glyph(second.clone().into()) }; kerning.add_pair(KerningPair::new( diff --git a/crates/shift-backends/src/lib.rs b/crates/shift-backends/src/lib.rs index 92c9d740..f95f7bc9 100644 --- a/crates/shift-backends/src/lib.rs +++ b/crates/shift-backends/src/lib.rs @@ -1,6 +1,10 @@ +pub mod binary; pub mod designspace; +pub mod errors; +pub mod font_loader; pub mod glyphs; mod traits; pub mod ufo; -pub use traits::{FontBackend, FontReader, FontWriter}; +pub use errors::{BackendError, BackendResult}; +pub use traits::{FontBackend, FontReader, FontView, FontWriter}; diff --git a/crates/shift-backends/src/traits.rs b/crates/shift-backends/src/traits.rs index 08c255f8..72d568ee 100644 --- a/crates/shift-backends/src/traits.rs +++ b/crates/shift-backends/src/traits.rs @@ -1,4 +1,75 @@ -use shift_ir::{FeatureData, Font, Glyph, GlyphName, KerningData}; +use shift_ir::{ + Axis, FeatureData, Font, FontMetadata, FontMetrics, Glyph, GlyphName, Guideline, KerningData, + Layer, LayerId, LibData, Source, +}; + +pub trait FontView { + fn metadata(&self) -> &FontMetadata; + fn metrics(&self) -> &FontMetrics; + fn axes(&self) -> &[Axis]; + fn sources(&self) -> &[Source]; + fn layers(&self) -> Vec<(LayerId, &Layer)>; + fn glyphs(&self) -> Vec<&Glyph>; + fn glyph(&self, name: &str) -> Option<&Glyph>; + fn kerning(&self) -> &KerningData; + fn features(&self) -> &FeatureData; + fn guidelines(&self) -> &[Guideline]; + fn lib(&self) -> &LibData; + fn default_layer_id(&self) -> LayerId; +} + +impl FontView for Font { + fn metadata(&self) -> &FontMetadata { + self.metadata() + } + + fn metrics(&self) -> &FontMetrics { + self.metrics() + } + + fn axes(&self) -> &[Axis] { + self.axes() + } + + fn sources(&self) -> &[Source] { + self.sources() + } + + fn layers(&self) -> Vec<(LayerId, &Layer)> { + self.layers() + .iter() + .map(|(layer_id, layer)| (*layer_id, layer)) + .collect() + } + + fn glyphs(&self) -> Vec<&Glyph> { + self.glyphs().values().map(|glyph| glyph.as_ref()).collect() + } + + fn glyph(&self, name: &str) -> Option<&Glyph> { + self.glyph(name) + } + + fn kerning(&self) -> &KerningData { + self.kerning() + } + + fn features(&self) -> &FeatureData { + self.features() + } + + fn guidelines(&self) -> &[Guideline] { + self.guidelines() + } + + fn lib(&self) -> &LibData { + self.lib() + } + + fn default_layer_id(&self) -> LayerId { + self.default_layer_id() + } +} pub trait FontReader: Send + Sync { fn load(&self, path: &str) -> Result; diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index 6937074a..f07a563a 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -147,12 +147,12 @@ impl UfoReader { if key_str.starts_with("public.kern1.") { kerning.set_group1( key_str.to_string(), - members.iter().map(|n| n.to_string()).collect(), + members.iter().map(|n| n.to_string().into()).collect(), ); } else if key_str.starts_with("public.kern2.") { kerning.set_group2( key_str.to_string(), - members.iter().map(|n| n.to_string()).collect(), + members.iter().map(|n| n.to_string().into()).collect(), ); } } @@ -162,7 +162,7 @@ impl UfoReader { let first_side = if first_str.starts_with("public.kern1.") { KerningSide::Group(first_str.to_string()) } else { - KerningSide::Glyph(first_str.to_string()) + KerningSide::Glyph(first_str.into()) }; for (second, value) in seconds.iter() { @@ -170,7 +170,7 @@ impl UfoReader { let second_side = if second_str.starts_with("public.kern2.") { KerningSide::Group(second_str.to_string()) } else { - KerningSide::Glyph(second_str.to_string()) + KerningSide::Glyph(second_str.into()) }; kerning.add_pair(KerningPair::new(first_side.clone(), second_side, *value)); diff --git a/crates/shift-backends/src/ufo/writer.rs b/crates/shift-backends/src/ufo/writer.rs index 312686ac..ada96abc 100644 --- a/crates/shift-backends/src/ufo/writer.rs +++ b/crates/shift-backends/src/ufo/writer.rs @@ -1,4 +1,4 @@ -use crate::traits::FontWriter; +use crate::traits::{FontView, FontWriter}; use norad::{Font as NoradFont, Glyph as NoradGlyph, Line, Name}; use shift_ir::{ Contour, Font, Glyph, GlyphLayer, Guideline, KerningSide, LibData, LibValue, Point, PointType, @@ -203,8 +203,8 @@ impl Default for UfoWriter { } } -impl FontWriter for UfoWriter { - fn save(&self, font: &Font, path: &str) -> Result<(), String> { +impl UfoWriter { + pub fn save_view(&self, font: &impl FontView, path: &str) -> Result<(), String> { let path_obj = Path::new(path); if path_obj.exists() { std::fs::remove_dir_all(path_obj) @@ -280,7 +280,7 @@ impl FontWriter for UfoWriter { let default_layer_id = font.default_layer_id(); let default_layer = norad_font.layers.default_layer_mut(); - for glyph in font.glyphs().values() { + for glyph in font.glyphs() { if let Some(layer_data) = glyph.layer(default_layer_id) { let norad_glyph = Self::convert_glyph(glyph, layer_data); default_layer.insert_glyph(norad_glyph); @@ -288,7 +288,7 @@ impl FontWriter for UfoWriter { } for (layer_id, layer) in font.layers() { - if *layer_id == default_layer_id { + if layer_id == default_layer_id { continue; } @@ -297,8 +297,8 @@ impl FontWriter for UfoWriter { .new_layer(layer.name()) .map_err(|e| e.to_string())?; - for glyph in font.glyphs().values() { - if let Some(layer_data) = glyph.layer(*layer_id) { + for glyph in font.glyphs() { + if let Some(layer_data) = glyph.layer(layer_id) { let norad_glyph = Self::convert_glyph(glyph, layer_data); norad_layer.insert_glyph(norad_glyph); } @@ -314,3 +314,9 @@ impl FontWriter for UfoWriter { norad_font.save(path).map_err(|e| e.to_string()) } } + +impl FontWriter for UfoWriter { + fn save(&self, font: &Font, path: &str) -> Result<(), String> { + self.save_view(font, path) + } +} diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs new file mode 100644 index 00000000..2d5e99bd --- /dev/null +++ b/crates/shift-backends/tests/loading.rs @@ -0,0 +1,230 @@ +use std::path::{Path, PathBuf}; + +use shift_backends::font_loader::FontLoader; +use shift_ir::{Font, Glyph, GlyphLayer, PointType}; + +fn fixtures_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("fixtures") +} + +fn mutatorsans_ufo_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") +} + +fn mutatorsans_ttf_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans/MutatorSans.ttf") +} + +fn mutatorsans_otf_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans/MutatorSans.otf") +} + +fn homenaje_glyphs_path() -> PathBuf { + fixtures_path().join("fonts/Homenaje.glyphs") +} + +fn mutatorsans_variable_glyphs_path() -> PathBuf { + fixtures_path().join("fonts/MutatorSansVariable.glyphs") +} + +fn mutatorsans_designspace_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans-variable/MutatorSans.designspace") +} + +fn load_font(path: &Path) -> Font { + assert!(path.exists(), "missing font fixture at {}", path.display()); + FontLoader::new() + .read_font(path.to_str().unwrap()) + .unwrap_or_else(|error| panic!("failed to load {}: {error}", path.display())) +} + +fn main_layer(glyph: &Glyph) -> &GlyphLayer { + glyph + .layers() + .values() + .max_by_key(|layer| layer.contours().len()) + .expect("glyph should have at least one layer") +} + +#[test] +fn loads_ufo_metadata_metrics_and_geometry() { + let font = load_font(&mutatorsans_ufo_path()); + let metadata = font.metadata(); + let metrics = font.metrics(); + + assert_eq!(font.glyph_count(), 48); + assert_eq!(metadata.family_name.as_deref(), Some("MutatorMathTest")); + assert_eq!(metadata.style_name.as_deref(), Some("LightCondensed")); + assert_eq!(metrics.units_per_em, 1000.0); + assert_eq!(metrics.ascender, 700.0); + assert_eq!(metrics.descender, -200.0); + assert_eq!(metrics.cap_height, Some(700.0)); + assert_eq!(metrics.x_height, Some(500.0)); + + let glyph_a = font.glyph("A").expect("A glyph should exist"); + assert!(!main_layer(glyph_a).contours().is_empty()); + + let glyph_o = font.glyph("O").expect("O glyph should exist"); + let has_off_curve = main_layer(glyph_o) + .contours_iter() + .flat_map(|contour| contour.points()) + .any(|point| point.point_type() == PointType::OffCurve); + assert!(has_off_curve, "O should contain curve control points"); +} + +#[test] +fn loads_ufo_components_anchors_layers_and_kerning() { + let font = load_font(&mutatorsans_ufo_path()); + + let aacute = font.glyph("Aacute").expect("Aacute glyph should exist"); + let component_bases: Vec<_> = main_layer(aacute) + .components_iter() + .map(|component| component.base_glyph().as_str()) + .collect(); + assert_eq!(component_bases.len(), 2); + assert!(component_bases.contains(&"A")); + assert!(component_bases.contains(&"acute")); + + let e = font.glyph("E").expect("E glyph should exist"); + let anchor_names: Vec<_> = e + .layers() + .values() + .flat_map(|layer| layer.anchors_iter()) + .filter_map(|anchor| anchor.name()) + .collect(); + assert!(anchor_names.contains(&"top")); + + let layer_names: Vec<_> = font.layers().values().map(|layer| layer.name()).collect(); + assert!(layer_names.contains(&"public.default")); + assert!(font.layers().len() >= 2); + + assert_eq!( + font.kerning() + .get_kerning("T", "A"), + Some(-75.0) + ); + assert_eq!( + font.kerning() + .get_kerning("V", "A"), + Some(-100.0) + ); +} + +#[test] +fn loads_binary_fonts_with_contours() { + for path in [mutatorsans_ttf_path(), mutatorsans_otf_path()] { + let font = load_font(&path); + let glyph_a = font + .glyph_by_unicode(65) + .unwrap_or_else(|| panic!("{} should contain U+0041", path.display())); + + assert!(font.glyph_count() > 0); + assert!(!main_layer(glyph_a).contours().is_empty()); + } +} + +#[test] +fn loads_glyphs_file_features_kerning_components_and_anchors() { + let font = load_font(&homenaje_glyphs_path()); + + assert_eq!(font.metadata().family_name.as_deref(), Some("Homenaje")); + assert_eq!(font.metrics().units_per_em, 1000.0); + assert_eq!(font.metrics().ascender, 700.0); + assert_eq!(font.metrics().descender, -160.0); + assert!(font.glyph_count() >= 300); + + let fea = font + .features() + .fea_source() + .expect("Homenaje should include feature source"); + assert!(fea.contains("feature locl")); + assert!(fea.contains("feature frac")); + assert!(fea.contains("feature ordn")); + + assert_eq!( + font.kerning() + .get_kerning("A", "V"), + Some(-55.0) + ); + assert_eq!( + font.kerning() + .get_kerning("V", "a"), + Some(-65.0) + ); + + let aacute = font.glyph("Aacute").expect("Aacute glyph should exist"); + let component_bases: Vec<_> = main_layer(aacute) + .components_iter() + .map(|component| component.base_glyph().as_str()) + .collect(); + assert_eq!(component_bases.len(), 2); + assert!(component_bases.contains(&"A")); + assert!(component_bases.contains(&"acute")); + + let u = font.glyph("u").expect("u glyph should exist"); + let anchor_names: Vec<_> = main_layer(u) + .anchors_iter() + .filter_map(|anchor| anchor.name()) + .collect(); + assert!(anchor_names.contains(&"top")); + assert!(anchor_names.contains(&"bottom")); + assert!(anchor_names.contains(&"ogonek")); +} + +#[test] +fn loads_variable_glyphs_sources_and_compatible_layers() { + let font = load_font(&mutatorsans_variable_glyphs_path()); + + assert!(font.is_variable()); + assert_eq!(font.axes().len(), 1); + assert_eq!(font.axes()[0].tag(), "wght"); + assert_eq!(font.axes()[0].minimum(), 100.0); + assert_eq!(font.axes()[0].maximum(), 900.0); + assert_eq!(font.sources().len(), 2); + assert_eq!(font.sources()[0].location().get("wght"), Some(100.0)); + assert_eq!(font.sources()[1].location().get("wght"), Some(900.0)); + + let glyph_a = font.glyph("A").expect("A glyph should exist"); + let layers: Vec<_> = glyph_a.layers().values().collect(); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].contours().len(), layers[1].contours().len()); + assert_eq!( + layers[0] + .contours() + .values() + .map(|contour| contour.points().len()) + .sum::(), + layers[1] + .contours() + .values() + .map(|contour| contour.points().len()) + .sum::() + ); +} + +#[test] +fn loads_designspace_sources_axes_and_default_metadata() { + let font = load_font(&mutatorsans_designspace_path()); + + assert!(font.is_variable()); + assert_eq!( + font.metadata().family_name.as_deref(), + Some("MutatorMathTest") + ); + assert!(font.glyph_count() > 10); + assert_eq!(font.axes().len(), 2); + assert_eq!(font.axes()[0].tag(), "wdth"); + assert_eq!(font.axes()[1].tag(), "wght"); + assert_eq!(font.sources().len(), 7); + assert_eq!(font.sources()[0].location().get("wdth"), Some(0.0)); + assert_eq!(font.sources()[0].location().get("wght"), Some(0.0)); + assert!(font.sources()[0].filename().is_some()); + + let glyph_a = font.glyph("A").expect("A glyph should exist"); + assert!(glyph_a.layers().len() >= 4); +} diff --git a/crates/shift-backends/tests/round_trip.rs b/crates/shift-backends/tests/round_trip.rs new file mode 100644 index 00000000..f0ad9f67 --- /dev/null +++ b/crates/shift-backends/tests/round_trip.rs @@ -0,0 +1,3 @@ +mod round_trip { + mod ufo; +} diff --git a/crates/shift-backends/tests/round_trip/ufo.rs b/crates/shift-backends/tests/round_trip/ufo.rs new file mode 100644 index 00000000..d1b39f0a --- /dev/null +++ b/crates/shift-backends/tests/round_trip/ufo.rs @@ -0,0 +1,203 @@ +use std::path::{Path, PathBuf}; + +use shift_backends::font_loader::FontLoader; +use shift_ir::{Anchor, Font, Glyph, GlyphLayer}; + +fn fixtures_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("fixtures") +} + +fn mutatorsans_ufo_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") +} + +fn load_font(path: &Path) -> Font { + assert!(path.exists(), "missing font fixture at {}", path.display()); + FontLoader::new() + .read_font(path.to_str().unwrap()) + .unwrap_or_else(|error| panic!("failed to load {}: {error}", path.display())) +} + +fn round_trip(font: &Font) -> Font { + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let output_path = temp_dir.path().join("round-trip.ufo"); + let loader = FontLoader::new(); + + loader + .write_font(font, output_path.to_str().unwrap()) + .expect("UFO writer should save the font"); + loader + .read_font(output_path.to_str().unwrap()) + .expect("UFO reader should reload the saved font") +} + +fn main_layer(glyph: &Glyph) -> &GlyphLayer { + glyph + .layers() + .values() + .max_by_key(|layer| layer.contours().len()) + .expect("glyph should have at least one layer") +} + +fn sorted_contours(layer: &GlyphLayer) -> Vec<&shift_ir::Contour> { + let mut contours: Vec<_> = layer.contours_iter().collect(); + contours.sort_by(|a, b| { + let a_first = a.points().first().map(|point| { + ( + (point.x() * 1000.0).round() as i64, + (point.y() * 1000.0).round() as i64, + ) + }); + let b_first = b.points().first().map(|point| { + ( + (point.x() * 1000.0).round() as i64, + (point.y() * 1000.0).round() as i64, + ) + }); + a_first.cmp(&b_first) + }); + contours +} + +fn assert_layer_geometry_matches(original: &GlyphLayer, reloaded: &GlyphLayer) { + let original_contours = sorted_contours(original); + let reloaded_contours = sorted_contours(reloaded); + + assert_eq!(original_contours.len(), reloaded_contours.len()); + for (original_contour, reloaded_contour) in original_contours.iter().zip(&reloaded_contours) { + assert_eq!(original_contour.is_closed(), reloaded_contour.is_closed()); + assert_eq!( + original_contour.points().len(), + reloaded_contour.points().len() + ); + + for (original_point, reloaded_point) in original_contour + .points() + .iter() + .zip(reloaded_contour.points()) + { + assert!((original_point.x() - reloaded_point.x()).abs() < 0.001); + assert!((original_point.y() - reloaded_point.y()).abs() < 0.001); + assert_eq!(original_point.point_type(), reloaded_point.point_type()); + assert_eq!(original_point.is_smooth(), reloaded_point.is_smooth()); + } + } +} + +#[test] +fn preserves_metadata_metrics_and_glyph_count() { + let original = load_font(&mutatorsans_ufo_path()); + let reloaded = round_trip(&original); + + assert_eq!(reloaded.glyph_count(), original.glyph_count()); + assert_eq!( + reloaded.metadata().family_name, + original.metadata().family_name + ); + assert_eq!( + reloaded.metadata().style_name, + original.metadata().style_name + ); + assert_eq!( + reloaded.metrics().units_per_em, + original.metrics().units_per_em + ); + assert_eq!(reloaded.metrics().ascender, original.metrics().ascender); + assert_eq!(reloaded.metrics().descender, original.metrics().descender); + assert_eq!(reloaded.metrics().cap_height, original.metrics().cap_height); + assert_eq!(reloaded.metrics().x_height, original.metrics().x_height); +} + +#[test] +fn preserves_contours_points_and_widths_for_default_geometry() { + let original = load_font(&mutatorsans_ufo_path()); + let reloaded = round_trip(&original); + + for glyph_name in ["A", "O"] { + let original_layer = main_layer( + original + .glyph(glyph_name) + .unwrap_or_else(|| panic!("original {glyph_name} should exist")), + ); + let reloaded_layer = main_layer( + reloaded + .glyph(glyph_name) + .unwrap_or_else(|| panic!("reloaded {glyph_name} should exist")), + ); + + assert!((original_layer.width() - reloaded_layer.width()).abs() < 0.001); + assert_layer_geometry_matches(original_layer, reloaded_layer); + } +} + +#[test] +fn preserves_components_anchors_layers_and_kerning() { + let mut original = load_font(&mutatorsans_ufo_path()); + original + .glyph_mut("E") + .expect("E glyph should exist") + .layers() + .iter() + .max_by_key(|(_, layer)| layer.contours().len()) + .map(|(layer_id, _)| *layer_id) + .and_then(|layer_id| original.glyph_mut("E").unwrap().layer_mut(layer_id)) + .expect("E should have a main layer") + .add_anchor(Anchor::new(None::, 123.0, 456.0)); + + let reloaded = round_trip(&original); + + let aacute = reloaded.glyph("Aacute").expect("Aacute should exist"); + let component_bases: Vec<_> = main_layer(aacute) + .components_iter() + .map(|component| component.base_glyph().as_str()) + .collect(); + assert_eq!(component_bases.len(), 2); + assert!(component_bases.contains(&"A")); + assert!(component_bases.contains(&"acute")); + + let e = reloaded.glyph("E").expect("E should exist"); + let anchors: Vec<_> = e + .layers() + .values() + .flat_map(|layer| layer.anchors_iter()) + .collect(); + assert!(anchors.iter().any(|anchor| anchor.name() == Some("top"))); + assert!(anchors.iter().any(|anchor| { + anchor.name().is_none() + && (anchor.x() - 123.0).abs() < 0.001 + && (anchor.y() - 456.0).abs() < 0.001 + })); + + let original_layer_names: Vec<_> = original + .layers() + .values() + .map(|layer| layer.name()) + .collect(); + let reloaded_layer_names: Vec<_> = reloaded + .layers() + .values() + .map(|layer| layer.name()) + .collect(); + assert_eq!(reloaded_layer_names.len(), original_layer_names.len()); + for name in original_layer_names { + assert!(reloaded_layer_names.contains(&name)); + } + + assert_eq!( + reloaded + .kerning() + .get_kerning("T", "A"), + Some(-75.0) + ); + assert_eq!( + reloaded + .kerning() + .get_kerning("V", "A"), + Some(-100.0) + ); +} diff --git a/crates/shift-node/.github/workflows/CI.yml b/crates/shift-bridge/.github/workflows/CI.yml similarity index 100% rename from crates/shift-node/.github/workflows/CI.yml rename to crates/shift-bridge/.github/workflows/CI.yml diff --git a/crates/shift-node/.gitignore b/crates/shift-bridge/.gitignore similarity index 100% rename from crates/shift-node/.gitignore rename to crates/shift-bridge/.gitignore diff --git a/crates/shift-node/.npmignore b/crates/shift-bridge/.npmignore similarity index 100% rename from crates/shift-node/.npmignore rename to crates/shift-bridge/.npmignore diff --git a/crates/shift-node/.yarnrc.yml b/crates/shift-bridge/.yarnrc.yml similarity index 100% rename from crates/shift-node/.yarnrc.yml rename to crates/shift-bridge/.yarnrc.yml diff --git a/crates/shift-bridge/Cargo.toml b/crates/shift-bridge/Cargo.toml new file mode 100644 index 00000000..a67b2bf8 --- /dev/null +++ b/crates/shift-bridge/Cargo.toml @@ -0,0 +1,24 @@ +[package] +edition = "2021" +name = "shift-bridge" +version = "0.0.1" +authors = ["Kostya Farber "] +license = "MIT" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shift-edit = { workspace = true } +shift-wire = { workspace = true, features = ["napi"] } +shift-ir = { workspace = true } +shift-backends = { workspace = true } + +napi = { version = "=3.8.6", default-features = false, features = ["napi6"] } +napi-derive = "=3.5.5" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0.18" + +[build-dependencies] +napi-build = "2.1" diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs new file mode 100644 index 00000000..68d830c7 --- /dev/null +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createRequire } from "module"; +import { existsSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const require = createRequire(import.meta.url); +const { Bridge } = require("../index.js"); + +describe("Bridge", () => { + let bridge; + + beforeEach(() => { + bridge = new Bridge(); + }); + + it("starts with default committed font metadata", () => { + expect(bridge.getMetadata()).toMatchObject({ + familyName: "Untitled Font", + styleName: "Regular", + versionMajor: 1, + versionMinor: 0, + }); + + expect(bridge.getMetrics()).toMatchObject({ + unitsPerEm: 1000, + ascender: 800, + descender: -200, + capHeight: 700, + xHeight: 500, + }); + + expect(bridge.getGlyphCount()).toBe(0); + expect(bridge.getGlyphs()).toEqual([]); + }); + + it("commits a new glyph when the edit session ends", () => { + bridge.startEditSession({ name: "A", unicode: 65 }); + expect(bridge.hasEditSession()).toBe(true); + expect(bridge.getEditingGlyphName()).toBe("A"); + expect(bridge.getEditingUnicode()).toBe(65); + + bridge.endEditSession(); + + expect(bridge.hasEditSession()).toBe(false); + expect(bridge.getGlyphs()).toEqual([ + { name: "A", unicodes: [65], componentBaseGlyphNames: [] }, + ]); + }); + + it("saves the active edit snapshot without ending the session", () => { + const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-save-")); + try { + bridge.startEditSession({ name: "A", unicode: 65 }); + const contourId = bridge.addContour().changed.contourIds[0]; + bridge.addPoint(contourId, 10, 20, "onCurve", false); + + const outputPath = join(tempDir, "output.ufo"); + const savedVersion = bridge.saveFont(outputPath); + + expect(savedVersion).toBe(2); + expect(bridge.hasEditSession()).toBe(true); + expect(bridge.getPersistedVersion()).toBe(2); + expect(bridge.isDirty()).toBe(false); + expect(existsSync(outputPath)).toBe(true); + + const reloaded = new Bridge(); + reloaded.loadFont(outputPath); + expect(reloaded.getGlyphs()).toEqual([ + { name: "A", unicodes: [65], componentBaseGlyphNames: [] }, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("records the persisted version when an async save completes", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-async-save-")); + try { + bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.addContour(); + + const outputPath = join(tempDir, "async-output.ufo"); + const savedVersion = await bridge.saveFontAsync(outputPath); + + expect(savedVersion).toBe(1); + expect(bridge.getPersistedVersion()).toBe(1); + expect(bridge.isDirty()).toBe(false); + expect(existsSync(outputPath)).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects starting a second active edit session", () => { + bridge.startEditSession({ name: "A", unicode: 65 }); + + expect(() => bridge.startEditSession({ name: "B", unicode: 66 })).toThrow( + /edit session already active/i, + ); + expect(bridge.getEditingGlyphName()).toBe("A"); + }); + + it("adds a point to a contour and returns structure, values, and changed ids", () => { + bridge.startEditSession({ name: "A", unicode: 65 }); + const contourChange = bridge.addContour(); + const contourId = contourChange.changed.contourIds[0]; + + const change = bridge.addPoint(contourId, 10, 20, "onCurve", false); + + expect(change.changed.pointIds).toHaveLength(1); + expect(change.structure.contours).toHaveLength(1); + expect(change.structure.contours[0]).toMatchObject({ + id: contourId, + closed: false, + }); + expect(change.structure.contours[0].points).toEqual([ + { + id: change.changed.pointIds[0], + pointType: "onCurve", + smooth: false, + }, + ]); + expect(Array.from(change.values)).toEqual([500, 10, 20]); + }); + + it("sets point positions through the bulk typed-array hot path", () => { + bridge.startEditSession({ name: "A", unicode: 65 }); + const contourId = bridge.addContour().changed.contourIds[0]; + const pointId = bridge.addPoint(contourId, 10, 20, "onCurve", false).changed.pointIds[0]; + + const change = bridge.setPositions( + new BigUint64Array([BigInt(pointId)]), + new Float64Array([30, 40]), + null, + null, + ); + + expect(change.changed.pointIds).toEqual([pointId]); + expect(Array.from(change.values)).toEqual([500, 30, 40]); + }); + + it("restores structure and values into the active session", () => { + bridge.startEditSession({ name: "A", unicode: 65 }); + const contourId = bridge.addContour().changed.contourIds[0]; + const before = bridge.addPoint(contourId, 10, 20, "onCurve", false); + const pointId = before.changed.pointIds[0]; + + const change = bridge.restoreState(before.structure, new Float64Array([700, 90, 120])); + + expect(change.structure.contours[0].points[0].id).toBe(pointId); + expect(Array.from(change.values)).toEqual([700, 90, 120]); + }); + + it("surfaces typed bridge errors at the NAPI boundary", () => { + expect(() => bridge.addContour()).toThrow(/active edit/i); + + bridge.startEditSession({ name: "A", unicode: 65 }); + expect(() => bridge.addPoint("not-a-contour", 10, 20, "onCurve", false)).toThrow(/contour ID/i); + expect(() => + bridge.setPositions(new BigUint64Array([1n]), new Float64Array([10]), null, null), + ).toThrow(/point positions/i); + }); +}); diff --git a/crates/shift-bridge/build.rs b/crates/shift-bridge/build.rs new file mode 100644 index 00000000..7b286945 --- /dev/null +++ b/crates/shift-bridge/build.rs @@ -0,0 +1,11 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); + + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-arg=-undefined"); + println!("cargo:rustc-link-arg=dynamic_lookup"); + } +} diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md new file mode 100644 index 00000000..bdca33bf --- /dev/null +++ b/crates/shift-bridge/docs/DOCS.md @@ -0,0 +1,86 @@ +# shift-bridge + +NAPI bindings that expose the Rust font/editing engine to Node.js and Electron as a `Bridge` class. + +## Architecture Invariants + +**Architecture Invariant:** Only one `ActiveEdit` may be active at a time. Starting a second edit session returns a typed bridge error. **WHY:** The bridge owns one mutable edit surface and saves use an overlay snapshot of that active glyph. + +**Architecture Invariant:** Public bridge DTOs live in `shift-wire`; NAPI-specific wrappers live under `shift-wire::bridges::napi`. **WHY:** Wire shapes remain independent of the native module implementation, while NAPI can still return efficient types such as `Float64Array`. + +**Architecture Invariant:** Bridge methods return native NAPI values, not JSON strings. Domain failures flow through `BridgeError` and are converted at the NAPI boundary. **WHY:** Rust keeps typed errors internally, and TypeScript receives normal exceptions plus generated declaration types. + +**Architecture Invariant:** Bulk position updates use `Float64Array` values plus typed node descriptors. **WHY:** Drag updates keep the hot path in flat numeric buffers while IDs remain branded strings at the API boundary. + +**Architecture Invariant:** Save uses a clone/COW `FontSaveSnapshot` plus an active glyph overlay. **WHY:** Async save can run from a stable view of the font without ending or mutating the current edit session. + +## Codemap + +``` +crates/shift-bridge/ + src/ + lib.rs -- crate root + bridge.rs -- `Bridge` NAPI class, session lifecycle, font reads, save task + errors.rs -- bridge error type and NAPI mapping + input.rs -- boundary parsing/adaptation helpers + Cargo.toml -- cdylib crate; depends on shift-edit, shift-wire, shift-backends, napi +``` + +## Key Types + +- `Bridge` -- the exported `#[napi]` class holding the committed `Font`, optional `ActiveEdit`, and document versions. +- `ActiveEdit` -- active glyph/session/layer bundle used while editing. +- `FontSaveSnapshot` -- clone/COW save view with an optional active glyph override. +- `SaveFontTask` -- NAPI `Task` implementation for async font saving. +- `BridgeError` -- typed bridge error enum converted once at the NAPI boundary. +- `GlyphStructureChange` / `GlyphValueChange` -- canonical wire DTOs returned by edit mutations. +- `NapiGlyphStructureChange` / `NapiGlyphValueChange` -- NAPI adapters for those DTOs. + +## Session Lifecycle + +1. JS calls `startEditSession(GlyphHandle)`. +2. `Bridge` creates or finds the glyph, removes the editable layer into an `EditSession`, and stores it in `active_edit`. +3. Mutation methods parse boundary inputs, borrow the active session, call `EditSession`, then return a wire change object. +4. `endEditSession` commits the session layer back into the glyph and stores it in the font. +5. `saveFont(path)` creates a `FontSaveSnapshot` and saves asynchronously through `shift-backends`. + +## Type Boundary + +`crates/shift-bridge/index.d.ts` is generated by napi-rs from the NAPI wrappers. The root typegen script derives `packages/types/src/bridge/generated.ts` from that declaration file and removes `Napi*` prefixes for the TypeScript DTO facade. + +`dts-header.d.ts` is prepended to the napi-rs declaration output so generated signatures can reference branded IDs such as `PointId`, `ContourId`, `LayerId`, and `SourceId` from `@shift/types`. + +## Workflow Recipes + +### Adding a new mutation method + +1. Add the domain operation to `EditSession` in `shift-edit`. +2. Add or reuse a canonical DTO in `shift-wire`. +3. Add a NAPI adapter in `shift-wire::bridges::napi` only if NAPI needs a different representation. +4. Add the `#[napi]` method on `Bridge`. +5. Parse string IDs through `input.rs`, call the session, then return the appropriate wire change. +6. Run `cargo check -p shift-bridge` and rebuild the native module before regenerating bridge types. + +### Adding a new read-only query + +1. Prefer committed-font reads unless the method is explicitly about the active edit session. +2. Return native NAPI DTOs rather than serialized JSON. +3. Keep editor/rendering concerns out of Rust; TypeScript owns canvas-specific interpretation. + +## Verification + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +pnpm --filter shift-bridge run build:debug +pnpm generate:bridge-types +``` + +## Related + +- `shift-edit` -- edit session and glyph mutation logic. +- `shift-ir` -- font/glyph/layer data model. +- `shift-wire` -- canonical bridge DTOs and NAPI adapters. +- `shift-backends` -- font loading/saving. +- `packages/types/src/bridge` -- generated TypeScript bridge facade. diff --git a/crates/shift-bridge/dts-header.d.ts b/crates/shift-bridge/dts-header.d.ts new file mode 100644 index 00000000..31f3c9e2 --- /dev/null +++ b/crates/shift-bridge/dts-header.d.ts @@ -0,0 +1,11 @@ +import type { + ContourId, + PointId, + AnchorId, + ComponentId, + GuidelineId, + GlyphName, + LayerId, + SourceId, + Unicode, +} from "@shift/types"; diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts new file mode 100644 index 00000000..85f1bc04 --- /dev/null +++ b/crates/shift-bridge/index.d.ts @@ -0,0 +1,191 @@ +import type { + ContourId, + PointId, + AnchorId, + ComponentId, + GuidelineId, + GlyphName, + LayerId, + SourceId, + Unicode, +} from "@shift/types"; +export declare class Bridge { + constructor() + loadFont(path: string): void + saveFont(path: string): Promise + getMetadata(): NapiFontMetadata + getMetrics(): NapiFontMetrics + getGlyphCount(): number + getGlyphs(): Array + getGlyphState(glyphRef: GlyphHandle): NapiGlyphState | null + isVariable(): boolean + getAxes(): Array + getSources(): Array + startEditSession(glyphRef: GlyphHandle): void + getLiveVersion(): number + getPersistedVersion(): number + isDirty(): boolean + endEditSession(): void + hasEditSession(): boolean + getEditingUnicode(): Unicode | null + getEditingGlyphName(): GlyphName | null + setXAdvance(width: number): NapiGlyphValueChange + translateLayer(dx: number, dy: number): NapiGlyphValueChange + addPoint(contourId: ContourId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange + insertPointBefore(beforePointId: PointId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange + addContour(): NapiGlyphStructureChange + openContour(contourId: ContourId): NapiGlyphStructureChange + closeContour(contourId: ContourId): NapiGlyphStructureChange + reverseContour(contourId: ContourId): NapiGlyphStructureChange + applyBooleanOp(contourIdA: ContourId, contourIdB: ContourId, operation: string): NapiGlyphStructureChange + removePoints(pointIds: Array): NapiGlyphStructureChange + toggleSmooth(pointId: PointId): NapiGlyphStructureChange + /** + * Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. + * Coords are interleaved [x0, y0, x1, y1, ...]. + */ + setPositions(pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): NapiGlyphValueChange + restoreState(structure: NapiGlyphStructure, values: Float64Array): NapiGlyphStructureChange +} + +export interface GlyphHandle { + name: GlyphName + unicode?: Unicode +} +export interface NapiAnchorData { + id: AnchorId + name?: string +} + +export interface NapiAxis { + tag: string + name: string + minimum: number + default: number + maximum: number + hidden: boolean +} + +export interface NapiAxisTent { + axisTag: string + lower: number + peak: number + upper: number +} + +export interface NapiComponentData { + id: ComponentId + baseGlyphName: GlyphName +} + +export interface NapiContourData { + id: ContourId + points: Array + closed: boolean +} + +export interface NapiFontMetadata { + familyName?: string + styleName?: string + versionMajor?: number + versionMinor?: number + copyright?: string + trademark?: string + designer?: string + designerUrl?: string + manufacturer?: string + manufacturerUrl?: string + license?: string + licenseUrl?: string + description?: string + note?: string +} + +export interface NapiFontMetrics { + unitsPerEm: number + ascender: number + descender: number + capHeight?: number + xHeight?: number + lineGap?: number + italicAngle?: number + underlinePosition?: number + underlineThickness?: number +} + +export interface NapiGlyphChangedEntities { + pointIds: Array + contourIds: Array + anchorIds: Array + guidelineIds: Array + componentIds: Array +} + +export interface NapiGlyphMaster { + sourceId: SourceId + sourceName: string + isDefaultSource: boolean + location: NapiLocation + structure: NapiGlyphStructure + values: Float64Array +} + +export interface NapiGlyphRecord { + name: GlyphName + unicodes: Array + componentBaseGlyphNames: Array +} + +export interface NapiGlyphState { + structure: NapiGlyphStructure + /** Numeric glyph state ordered to match `GlyphStructure`. */ + values: Float64Array + variationData?: NapiGlyphVariationData +} + +export interface NapiGlyphStructure { + contours: Array + anchors: Array + components: Array +} + +export interface NapiGlyphStructureChange { + structure: NapiGlyphStructure + values: Float64Array + changed: NapiGlyphChangedEntities +} + +export interface NapiGlyphValueChange { + values: Float64Array + changed: NapiGlyphChangedEntities +} + +export interface NapiGlyphVariationData { + /** One entry per region. Inner = tents on the axes the region depends on. */ + regions: Array> + /** Deltas are flattened in `GlyphState::values` order. */ + deltas: Array +} + +export interface NapiLocation { + values: Record +} + +export interface NapiPointData { + id: PointId + pointType: NapiPointType + smooth: boolean +} + +export declare const enum NapiPointType { + OnCurve = 'onCurve', + OffCurve = 'offCurve' +} + +export interface NapiSource { + id: SourceId + name: string + location: NapiLocation + layerId: LayerId + filename?: string +} diff --git a/crates/shift-node/index.js b/crates/shift-bridge/index.js similarity index 74% rename from crates/shift-node/index.js rename to crates/shift-bridge/index.js index 3cfc00df..9baf0343 100644 --- a/crates/shift-node/index.js +++ b/crates/shift-bridge/index.js @@ -70,13 +70,13 @@ function requireNative() { } else if (process.platform === 'android') { if (process.arch === 'arm64') { try { - return require('./shift-node.android-arm64.node') + return require('./shift-bridge.android-arm64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-android-arm64') - const bindingPackageVersion = require('shift-node-android-arm64/package.json').version + const binding = require('shift-bridge-android-arm64') + const bindingPackageVersion = require('shift-bridge-android-arm64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -86,13 +86,13 @@ function requireNative() { } } else if (process.arch === 'arm') { try { - return require('./shift-node.android-arm-eabi.node') + return require('./shift-bridge.android-arm-eabi.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-android-arm-eabi') - const bindingPackageVersion = require('shift-node-android-arm-eabi/package.json').version + const binding = require('shift-bridge-android-arm-eabi') + const bindingPackageVersion = require('shift-bridge-android-arm-eabi/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -107,13 +107,13 @@ function requireNative() { if (process.arch === 'x64') { if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { try { - return require('./shift-node.win32-x64-gnu.node') + return require('./shift-bridge.win32-x64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-win32-x64-gnu') - const bindingPackageVersion = require('shift-node-win32-x64-gnu/package.json').version + const binding = require('shift-bridge-win32-x64-gnu') + const bindingPackageVersion = require('shift-bridge-win32-x64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -123,13 +123,13 @@ function requireNative() { } } else { try { - return require('./shift-node.win32-x64-msvc.node') + return require('./shift-bridge.win32-x64-msvc.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-win32-x64-msvc') - const bindingPackageVersion = require('shift-node-win32-x64-msvc/package.json').version + const binding = require('shift-bridge-win32-x64-msvc') + const bindingPackageVersion = require('shift-bridge-win32-x64-msvc/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -140,13 +140,13 @@ function requireNative() { } } else if (process.arch === 'ia32') { try { - return require('./shift-node.win32-ia32-msvc.node') + return require('./shift-bridge.win32-ia32-msvc.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-win32-ia32-msvc') - const bindingPackageVersion = require('shift-node-win32-ia32-msvc/package.json').version + const binding = require('shift-bridge-win32-ia32-msvc') + const bindingPackageVersion = require('shift-bridge-win32-ia32-msvc/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -156,13 +156,13 @@ function requireNative() { } } else if (process.arch === 'arm64') { try { - return require('./shift-node.win32-arm64-msvc.node') + return require('./shift-bridge.win32-arm64-msvc.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-win32-arm64-msvc') - const bindingPackageVersion = require('shift-node-win32-arm64-msvc/package.json').version + const binding = require('shift-bridge-win32-arm64-msvc') + const bindingPackageVersion = require('shift-bridge-win32-arm64-msvc/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -175,13 +175,13 @@ function requireNative() { } } else if (process.platform === 'darwin') { try { - return require('./shift-node.darwin-universal.node') + return require('./shift-bridge.darwin-universal.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-darwin-universal') - const bindingPackageVersion = require('shift-node-darwin-universal/package.json').version + const binding = require('shift-bridge-darwin-universal') + const bindingPackageVersion = require('shift-bridge-darwin-universal/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -191,13 +191,13 @@ function requireNative() { } if (process.arch === 'x64') { try { - return require('./shift-node.darwin-x64.node') + return require('./shift-bridge.darwin-x64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-darwin-x64') - const bindingPackageVersion = require('shift-node-darwin-x64/package.json').version + const binding = require('shift-bridge-darwin-x64') + const bindingPackageVersion = require('shift-bridge-darwin-x64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -207,13 +207,13 @@ function requireNative() { } } else if (process.arch === 'arm64') { try { - return require('./shift-node.darwin-arm64.node') + return require('./shift-bridge.darwin-arm64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-darwin-arm64') - const bindingPackageVersion = require('shift-node-darwin-arm64/package.json').version + const binding = require('shift-bridge-darwin-arm64') + const bindingPackageVersion = require('shift-bridge-darwin-arm64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -227,13 +227,13 @@ function requireNative() { } else if (process.platform === 'freebsd') { if (process.arch === 'x64') { try { - return require('./shift-node.freebsd-x64.node') + return require('./shift-bridge.freebsd-x64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-freebsd-x64') - const bindingPackageVersion = require('shift-node-freebsd-x64/package.json').version + const binding = require('shift-bridge-freebsd-x64') + const bindingPackageVersion = require('shift-bridge-freebsd-x64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -243,13 +243,13 @@ function requireNative() { } } else if (process.arch === 'arm64') { try { - return require('./shift-node.freebsd-arm64.node') + return require('./shift-bridge.freebsd-arm64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-freebsd-arm64') - const bindingPackageVersion = require('shift-node-freebsd-arm64/package.json').version + const binding = require('shift-bridge-freebsd-arm64') + const bindingPackageVersion = require('shift-bridge-freebsd-arm64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -264,13 +264,13 @@ function requireNative() { if (process.arch === 'x64') { if (isMusl()) { try { - return require('./shift-node.linux-x64-musl.node') + return require('./shift-bridge.linux-x64-musl.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-x64-musl') - const bindingPackageVersion = require('shift-node-linux-x64-musl/package.json').version + const binding = require('shift-bridge-linux-x64-musl') + const bindingPackageVersion = require('shift-bridge-linux-x64-musl/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -280,13 +280,13 @@ function requireNative() { } } else { try { - return require('./shift-node.linux-x64-gnu.node') + return require('./shift-bridge.linux-x64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-x64-gnu') - const bindingPackageVersion = require('shift-node-linux-x64-gnu/package.json').version + const binding = require('shift-bridge-linux-x64-gnu') + const bindingPackageVersion = require('shift-bridge-linux-x64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -298,13 +298,13 @@ function requireNative() { } else if (process.arch === 'arm64') { if (isMusl()) { try { - return require('./shift-node.linux-arm64-musl.node') + return require('./shift-bridge.linux-arm64-musl.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-arm64-musl') - const bindingPackageVersion = require('shift-node-linux-arm64-musl/package.json').version + const binding = require('shift-bridge-linux-arm64-musl') + const bindingPackageVersion = require('shift-bridge-linux-arm64-musl/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -314,13 +314,13 @@ function requireNative() { } } else { try { - return require('./shift-node.linux-arm64-gnu.node') + return require('./shift-bridge.linux-arm64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-arm64-gnu') - const bindingPackageVersion = require('shift-node-linux-arm64-gnu/package.json').version + const binding = require('shift-bridge-linux-arm64-gnu') + const bindingPackageVersion = require('shift-bridge-linux-arm64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -332,13 +332,13 @@ function requireNative() { } else if (process.arch === 'arm') { if (isMusl()) { try { - return require('./shift-node.linux-arm-musleabihf.node') + return require('./shift-bridge.linux-arm-musleabihf.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-arm-musleabihf') - const bindingPackageVersion = require('shift-node-linux-arm-musleabihf/package.json').version + const binding = require('shift-bridge-linux-arm-musleabihf') + const bindingPackageVersion = require('shift-bridge-linux-arm-musleabihf/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -348,13 +348,13 @@ function requireNative() { } } else { try { - return require('./shift-node.linux-arm-gnueabihf.node') + return require('./shift-bridge.linux-arm-gnueabihf.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-arm-gnueabihf') - const bindingPackageVersion = require('shift-node-linux-arm-gnueabihf/package.json').version + const binding = require('shift-bridge-linux-arm-gnueabihf') + const bindingPackageVersion = require('shift-bridge-linux-arm-gnueabihf/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -366,13 +366,13 @@ function requireNative() { } else if (process.arch === 'loong64') { if (isMusl()) { try { - return require('./shift-node.linux-loong64-musl.node') + return require('./shift-bridge.linux-loong64-musl.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-loong64-musl') - const bindingPackageVersion = require('shift-node-linux-loong64-musl/package.json').version + const binding = require('shift-bridge-linux-loong64-musl') + const bindingPackageVersion = require('shift-bridge-linux-loong64-musl/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -382,13 +382,13 @@ function requireNative() { } } else { try { - return require('./shift-node.linux-loong64-gnu.node') + return require('./shift-bridge.linux-loong64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-loong64-gnu') - const bindingPackageVersion = require('shift-node-linux-loong64-gnu/package.json').version + const binding = require('shift-bridge-linux-loong64-gnu') + const bindingPackageVersion = require('shift-bridge-linux-loong64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -400,13 +400,13 @@ function requireNative() { } else if (process.arch === 'riscv64') { if (isMusl()) { try { - return require('./shift-node.linux-riscv64-musl.node') + return require('./shift-bridge.linux-riscv64-musl.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-riscv64-musl') - const bindingPackageVersion = require('shift-node-linux-riscv64-musl/package.json').version + const binding = require('shift-bridge-linux-riscv64-musl') + const bindingPackageVersion = require('shift-bridge-linux-riscv64-musl/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -416,13 +416,13 @@ function requireNative() { } } else { try { - return require('./shift-node.linux-riscv64-gnu.node') + return require('./shift-bridge.linux-riscv64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-riscv64-gnu') - const bindingPackageVersion = require('shift-node-linux-riscv64-gnu/package.json').version + const binding = require('shift-bridge-linux-riscv64-gnu') + const bindingPackageVersion = require('shift-bridge-linux-riscv64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -433,13 +433,13 @@ function requireNative() { } } else if (process.arch === 'ppc64') { try { - return require('./shift-node.linux-ppc64-gnu.node') + return require('./shift-bridge.linux-ppc64-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-ppc64-gnu') - const bindingPackageVersion = require('shift-node-linux-ppc64-gnu/package.json').version + const binding = require('shift-bridge-linux-ppc64-gnu') + const bindingPackageVersion = require('shift-bridge-linux-ppc64-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -449,13 +449,13 @@ function requireNative() { } } else if (process.arch === 's390x') { try { - return require('./shift-node.linux-s390x-gnu.node') + return require('./shift-bridge.linux-s390x-gnu.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-linux-s390x-gnu') - const bindingPackageVersion = require('shift-node-linux-s390x-gnu/package.json').version + const binding = require('shift-bridge-linux-s390x-gnu') + const bindingPackageVersion = require('shift-bridge-linux-s390x-gnu/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -469,13 +469,13 @@ function requireNative() { } else if (process.platform === 'openharmony') { if (process.arch === 'arm64') { try { - return require('./shift-node.openharmony-arm64.node') + return require('./shift-bridge.openharmony-arm64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-openharmony-arm64') - const bindingPackageVersion = require('shift-node-openharmony-arm64/package.json').version + const binding = require('shift-bridge-openharmony-arm64') + const bindingPackageVersion = require('shift-bridge-openharmony-arm64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -485,13 +485,13 @@ function requireNative() { } } else if (process.arch === 'x64') { try { - return require('./shift-node.openharmony-x64.node') + return require('./shift-bridge.openharmony-x64.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-openharmony-x64') - const bindingPackageVersion = require('shift-node-openharmony-x64/package.json').version + const binding = require('shift-bridge-openharmony-x64') + const bindingPackageVersion = require('shift-bridge-openharmony-x64/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -501,13 +501,13 @@ function requireNative() { } } else if (process.arch === 'arm') { try { - return require('./shift-node.openharmony-arm.node') + return require('./shift-bridge.openharmony-arm.node') } catch (e) { loadErrors.push(e) } try { - const binding = require('shift-node-openharmony-arm') - const bindingPackageVersion = require('shift-node-openharmony-arm/package.json').version + const binding = require('shift-bridge-openharmony-arm') + const bindingPackageVersion = require('shift-bridge-openharmony-arm/package.json').version if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } @@ -529,7 +529,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { let wasiBinding = null let wasiBindingError = null try { - wasiBinding = require('./shift-node.wasi.cjs') + wasiBinding = require('./shift-bridge.wasi.cjs') nativeBinding = wasiBinding } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { @@ -538,7 +538,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { } if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { try { - wasiBinding = require('shift-node-wasm32-wasi') + wasiBinding = require('shift-bridge-wasm32-wasi') nativeBinding = wasiBinding } catch (err) { if (process.env.NAPI_RS_FORCE_WASI) { @@ -576,4 +576,5 @@ if (!nativeBinding) { } module.exports = nativeBinding -module.exports.FontEngine = nativeBinding.FontEngine +module.exports.Bridge = nativeBinding.Bridge +module.exports.NapiPointType = nativeBinding.NapiPointType diff --git a/crates/shift-node/package.json b/crates/shift-bridge/package.json similarity index 80% rename from crates/shift-node/package.json rename to crates/shift-bridge/package.json index 3ae38fe3..82668e84 100644 --- a/crates/shift-node/package.json +++ b/crates/shift-bridge/package.json @@ -1,10 +1,14 @@ { - "name": "shift-node", + "name": "shift-bridge", "version": "0.0.0", + "author": { + "name": "Kostya Farber", + "email": "kostya.farber@gmail.com" + }, "main": "index.js", "types": "index.d.ts", "napi": { - "binaryName": "shift-node", + "binaryName": "shift-bridge", "dtsHeaderFile": "dts-header.d.ts", "targets": [ "aarch64-apple-darwin", @@ -24,7 +28,7 @@ }, "license": "MIT", "devDependencies": { - "@napi-rs/cli": "^3.6.0", + "@napi-rs/cli": "3.6.2", "vitest": "^4.0.17" }, "engines": { @@ -38,5 +42,8 @@ "test": "vitest run", "universal": "napi universal", "version": "napi version" + }, + "dependencies": { + "@shift/types": "workspace:^" } } diff --git a/crates/shift-node/rustfmt.toml b/crates/shift-bridge/rustfmt.toml similarity index 100% rename from crates/shift-node/rustfmt.toml rename to crates/shift-bridge/rustfmt.toml diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs new file mode 100644 index 00000000..7c2db6bc --- /dev/null +++ b/crates/shift-bridge/src/bridge.rs @@ -0,0 +1,1149 @@ +use crate::errors::{self, to_napi_error, BridgeError, BridgeResult}; +use crate::input::parse; +use napi::bindgen_prelude::*; +use napi::{Error, Status}; +use napi_derive::napi; +use serde::{Deserialize, Serialize}; +use shift_backends::{font_loader::FontLoader, ufo::UfoWriter, FontView}; +use shift_edit::{ + edit_session::{BulkNodePositionUpdates, EditSession}, + interpolation::{build_masters, get_glyph_variation_data}, + BooleanOp, ContourId, Font, Glyph, GlyphLayer, LayerId, PointId, +}; +use shift_wire::{ + bridges::napi::{ + NapiAxis, NapiFontMetadata, NapiFontMetrics, NapiGlyphRecord, NapiGlyphState, + NapiGlyphStructure, NapiGlyphStructureChange, NapiGlyphValueChange, NapiPointType, NapiSource, + }, + Axis, FontMetadata, FontMetrics, GlyphChangedEntities, GlyphRecord, GlyphState, GlyphStructure, + GlyphStructureChange, GlyphValueChange, Source, +}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + +#[napi(object)] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GlyphHandle { + #[napi(ts_type = "GlyphName")] + pub name: String, + #[napi(ts_type = "Unicode")] + pub unicode: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct DocumentVersion(u64); + +impl DocumentVersion { + fn next(self) -> Self { + Self(self.0 + 1) + } + + fn as_u32(self) -> u32 { + self.0.min(u32::MAX as u64) as u32 + } +} + +type SharedPersistedVersion = Arc; + +fn record_persisted_version(persisted_version: &SharedPersistedVersion, version: DocumentVersion) { + let mut current = persisted_version.load(Ordering::Acquire); + while version.0 > current { + match persisted_version.compare_exchange( + current, + version.0, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return, + Err(observed) => current = observed, + } + } +} + +#[derive(Clone)] +pub struct FontSaveSnapshot { + version: DocumentVersion, + font: Font, + active_glyph_override: Option>, +} + +impl FontSaveSnapshot { + fn new(version: DocumentVersion, font: Font, active_glyph_override: Option) -> Self { + Self { + version, + font, + active_glyph_override: active_glyph_override.map(Arc::new), + } + } + + fn version(&self) -> DocumentVersion { + self.version + } +} + +impl FontView for FontSaveSnapshot { + fn metadata(&self) -> &shift_ir::FontMetadata { + self.font.metadata() + } + + fn metrics(&self) -> &shift_ir::FontMetrics { + self.font.metrics() + } + + fn axes(&self) -> &[shift_ir::Axis] { + self.font.axes() + } + + fn sources(&self) -> &[shift_ir::Source] { + self.font.sources() + } + + fn layers(&self) -> Vec<(LayerId, &shift_ir::Layer)> { + self + .font + .layers() + .iter() + .map(|(layer_id, layer)| (*layer_id, layer)) + .collect() + } + + fn glyphs(&self) -> Vec<&Glyph> { + let override_name = self + .active_glyph_override + .as_ref() + .map(|glyph| glyph.name().to_string()); + let mut glyphs = Vec::new(); + + if let Some(active_glyph) = self.active_glyph_override.as_ref() { + glyphs.push(active_glyph.as_ref()); + } + + glyphs.extend( + self + .font + .glyphs() + .values() + .map(|glyph| glyph.as_ref()) + .filter(|glyph| override_name.as_deref() != Some(glyph.name())), + ); + + glyphs + } + + fn glyph(&self, name: &str) -> Option<&Glyph> { + if let Some(active_glyph) = self.active_glyph_override.as_ref() { + if active_glyph.name() == name { + return Some(active_glyph.as_ref()); + } + } + + self.font.glyph(name) + } + + fn kerning(&self) -> &shift_ir::KerningData { + self.font.kerning() + } + + fn features(&self) -> &shift_ir::FeatureData { + self.font.features() + } + + fn guidelines(&self) -> &[shift_ir::Guideline] { + self.font.guidelines() + } + + fn lib(&self) -> &shift_ir::LibData { + self.font.lib() + } + + fn default_layer_id(&self) -> LayerId { + self.font.default_layer_id() + } +} + +pub struct SaveFontTask { + snapshot: FontSaveSnapshot, + persisted_version: SharedPersistedVersion, + path: String, +} + +impl Task for SaveFontTask { + type Output = DocumentVersion; + type JsValue = u32; + + fn compute(&mut self) -> Result { + UfoWriter::new() + .save_view(&self.snapshot, &self.path) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to save font: {e}")))?; + + Ok(self.snapshot.version()) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + record_persisted_version(&self.persisted_version, output); + Ok(output.as_u32()) + } +} + +pub struct ActiveEdit { + session: EditSession, + glyph: Glyph, + layer_id: LayerId, + dirty: bool, +} + +impl ActiveEdit { + fn new(session: EditSession, glyph: Glyph, layer_id: LayerId) -> Self { + Self { + session, + glyph, + layer_id, + dirty: false, + } + } + + fn from_glyph(glyph: Glyph, layer_id: LayerId, unicode_hint: Option) -> Self { + let unicode = glyph.primary_unicode().or(unicode_hint).unwrap_or(0); + let layer = glyph + .layer(layer_id) + .cloned() + .unwrap_or_else(|| GlyphLayer::with_width(500.0)); + let session = EditSession::new(glyph.name().to_string(), unicode, layer); + + Self::new(session, glyph, layer_id) + } + + fn session(&self) -> &EditSession { + &self.session + } + + fn session_mut(&mut self) -> &mut EditSession { + &mut self.session + } + + fn mark_dirty(&mut self) { + self.dirty = true; + } + + fn is_dirty(&self) -> bool { + self.dirty + } + + fn glyph_with_session_layer(&self) -> Glyph { + let mut glyph = self.glyph.clone(); + glyph.set_layer(self.layer_id, self.session.layer().clone()); + if self.session.unicode() != 0 { + glyph.add_unicode(self.session.unicode()); + } + glyph + } + + fn finish(self) -> Glyph { + let Self { + session, + mut glyph, + layer_id, + .. + } = self; + + let session_unicode = session.unicode(); + let layer = session.into_layer(); + glyph.set_layer(layer_id, layer); + if session_unicode != 0 { + glyph.add_unicode(session_unicode); + } + + glyph + } +} + +#[napi] +pub struct Bridge { + active_edit: Option, + font_loader: FontLoader, + font: Font, + live_version: DocumentVersion, + persisted_version: SharedPersistedVersion, +} + +#[napi] +impl Bridge { + #[napi(constructor)] + pub fn new() -> Self { + Self { + font_loader: FontLoader::new(), + active_edit: None, + font: Font::default(), + live_version: DocumentVersion::default(), + persisted_version: Arc::new(AtomicU64::new(0)), + } + } + + #[napi] + pub fn load_font(&mut self, path: String) -> errors::Result<()> { + self.font = self.font_loader.read_font(&path)?; + self.active_edit = None; + self.live_version = DocumentVersion::default(); + self.persisted_version = Arc::new(AtomicU64::new(0)); + Ok(()) + } + + #[napi(ts_return_type = "Promise")] + pub fn save_font(&mut self, path: String) -> AsyncTask { + AsyncTask::new(SaveFontTask { + snapshot: self.save_snapshot(), + persisted_version: self.persisted_version.clone(), + path, + }) + } + + #[napi] + pub fn get_metadata(&self) -> NapiFontMetadata { + FontMetadata::from(self.font.metadata()).into() + } + + #[napi] + pub fn get_metrics(&self) -> NapiFontMetrics { + FontMetrics::from(self.font.metrics()).into() + } + + #[napi] + pub fn get_glyph_count(&self) -> u32 { + self.font.glyph_count() as u32 + } + + #[napi] + pub fn get_glyphs(&self) -> Vec { + let mut records: Vec<_> = self + .font + .glyphs() + .values() + .map(|glyph| glyph.as_ref()) + .map(GlyphRecord::from) + .map(Into::into) + .collect(); + records.sort_by(|a: &NapiGlyphRecord, b: &NapiGlyphRecord| a.name.cmp(&b.name)); + records + } + + #[napi] + pub fn get_glyph_state(&self, glyph_ref: GlyphHandle) -> Option { + let glyph = self.glyph_for_read(&glyph_ref.name)?; + let layer = glyph.layer(self.font.default_layer_id())?; + let variation_data = build_masters(&self.font, &glyph) + .and_then(|masters| get_glyph_variation_data(&masters, self.font.axes())); + + Some(GlyphState::from_layer(layer, variation_data).into()) + } + + #[napi] + pub fn is_variable(&self) -> bool { + self.font.is_variable() + } + + #[napi] + pub fn get_axes(&self) -> Vec { + self + .font + .axes() + .iter() + .map(Axis::from) + .map(Into::into) + .collect() + } + + #[napi] + pub fn get_sources(&self) -> Vec { + self + .font + .sources() + .iter() + .map(Source::from) + .map(Into::into) + .collect() + } + + fn start_edit_session_for_name( + &mut self, + glyph_name: &str, + unicode_hint: Option, + ) -> Result<()> { + if self.active_edit.is_some() { + return Err(to_napi_error(BridgeError::ActiveEditAlreadyExists)); + } + + let default_layer_id = self.font.default_layer_id(); + let glyph = self + .font + .glyph(glyph_name) + .cloned() + .unwrap_or_else(|| Glyph::new(glyph_name.to_string())); + + self.active_edit = Some(ActiveEdit::from_glyph( + glyph, + default_layer_id, + unicode_hint, + )); + + Ok(()) + } + + #[napi] + pub fn start_edit_session(&mut self, glyph_ref: GlyphHandle) -> Result<()> { + self.start_edit_session_for_name(&glyph_ref.name, glyph_ref.unicode) + } + + fn active_edit(&self) -> BridgeResult<&ActiveEdit> { + self.active_edit.as_ref().ok_or(BridgeError::NoActiveEdit) + } + + fn active_edit_mut(&mut self) -> BridgeResult<&mut ActiveEdit> { + self.active_edit.as_mut().ok_or(BridgeError::NoActiveEdit) + } + + fn take_active_edit(&mut self) -> BridgeResult { + self.active_edit.take().ok_or(BridgeError::NoActiveEdit) + } + + fn active_session(&self) -> BridgeResult<&EditSession> { + Ok(self.active_edit()?.session()) + } + + fn active_session_mut(&mut self) -> BridgeResult<&mut EditSession> { + Ok(self.active_edit_mut()?.session_mut()) + } + + fn save_snapshot(&self) -> FontSaveSnapshot { + FontSaveSnapshot::new( + self.live_version(), + self.font.clone(), + self + .active_edit + .as_ref() + .map(ActiveEdit::glyph_with_session_layer), + ) + } + + fn glyph_for_read(&self, glyph_name: &str) -> Option { + self + .active_edit + .as_ref() + .filter(|active_edit| active_edit.session().glyph_name() == glyph_name) + .map(ActiveEdit::glyph_with_session_layer) + .or_else(|| self.font.glyph(glyph_name).cloned()) + } + + fn mark_active_edit_changed(&mut self) { + self.bump_live_version(); + if let Some(active_edit) = self.active_edit.as_mut() { + active_edit.mark_dirty(); + } + } + + fn mark_committed_changed(&mut self) { + self.bump_live_version(); + } + + fn bump_live_version(&mut self) { + self.live_version = self.live_version.next(); + } + + fn live_version(&self) -> DocumentVersion { + self.live_version + } + + fn persisted_version(&self) -> DocumentVersion { + DocumentVersion(self.persisted_version.load(Ordering::Acquire)) + } + + #[napi] + pub fn get_live_version(&self) -> u32 { + self.live_version().as_u32() + } + + #[napi] + pub fn get_persisted_version(&self) -> u32 { + self.persisted_version().as_u32() + } + + #[napi] + pub fn is_dirty(&self) -> bool { + self.persisted_version() < self.live_version() + } + + #[napi] + pub fn end_edit_session(&mut self) -> Result<()> { + let active_edit = self.take_active_edit()?; + let was_dirty = active_edit.is_dirty(); + let glyph = active_edit.finish(); + self.font.put_glyph(glyph); + if !was_dirty { + self.mark_committed_changed(); + } + + Ok(()) + } + + #[napi] + pub fn has_edit_session(&self) -> bool { + self.active_edit.is_some() + } + + #[napi(ts_return_type = "Unicode | null")] + pub fn get_editing_unicode(&self) -> Option { + self.active_session().ok().map(|session| session.unicode()) + } + + #[napi(ts_return_type = "GlyphName | null")] + pub fn get_editing_glyph_name(&self) -> Option { + self + .active_session() + .ok() + .map(|session| session.glyph_name().to_string()) + } + + #[napi] + pub fn set_x_advance(&mut self, width: f64) -> errors::Result { + let session = self.active_session_mut()?; + session.set_x_advance(width); + + let change = GlyphValueChange::from_layer(session.layer(), Default::default()); + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn translate_layer(&mut self, dx: f64, dy: f64) -> errors::Result { + let session = self.active_session_mut()?; + session.translate_layer(dx, dy); + + let change = GlyphValueChange::from_layer(session.layer(), Default::default()); + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn add_point( + &mut self, + #[napi(ts_arg_type = "ContourId")] contour_id: String, + x: f64, + y: f64, + point_type: NapiPointType, + smooth: bool, + ) -> errors::Result { + let contour_id = parse::(&contour_id)?; + let point_type = point_type.into(); + + let session = self.active_session_mut()?; + let point_id = session.add_point_to_contour(contour_id, x, y, point_type, smooth)?; + + let changed = GlyphChangedEntities { + point_ids: vec![point_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn insert_point_before( + &mut self, + #[napi(ts_arg_type = "PointId")] before_point_id: String, + x: f64, + y: f64, + point_type: NapiPointType, + smooth: bool, + ) -> errors::Result { + let before_point_id = parse::(&before_point_id)?; + let point_type = point_type.into(); + + let session = self.active_session_mut()?; + let point_id = session.insert_point_before(before_point_id, x, y, point_type, smooth)?; + + let changed = GlyphChangedEntities { + point_ids: vec![point_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn add_contour(&mut self) -> Result { + let session = self.active_session_mut()?; + let contour_id = session.add_empty_contour(); + + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn open_contour( + &mut self, + #[napi(ts_arg_type = "ContourId")] contour_id: String, + ) -> errors::Result { + let contour_id = parse::(&contour_id)?; + let session = self.active_session_mut()?; + session.open_contour(contour_id)?; + + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn close_contour( + &mut self, + #[napi(ts_arg_type = "ContourId")] contour_id: String, + ) -> errors::Result { + let contour_id = parse::(&contour_id)?; + let session = self.active_session_mut()?; + session.close_contour(contour_id)?; + + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn reverse_contour( + &mut self, + #[napi(ts_arg_type = "ContourId")] contour_id: String, + ) -> errors::Result { + let contour_id = parse::(&contour_id)?; + let session = self.active_session_mut()?; + session.reverse_contour(contour_id)?; + + let changed = GlyphChangedEntities { + contour_ids: vec![contour_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn apply_boolean_op( + &mut self, + #[napi(ts_arg_type = "ContourId")] contour_id_a: String, + #[napi(ts_arg_type = "ContourId")] contour_id_b: String, + operation: String, + ) -> errors::Result { + let cid_a = parse::(&contour_id_a)?; + let cid_b = parse::(&contour_id_b)?; + + let op = match operation.as_str() { + "union" => BooleanOp::Union, + "subtract" => BooleanOp::Subtract, + "intersect" => BooleanOp::Intersect, + "difference" => BooleanOp::Difference, + _ => { + return Err(errors::BridgeError::InvalidInput { + kind: "boolean operation", + value: operation, + }); + } + }; + + let session = self.active_session_mut()?; + let created_ids = session.apply_boolean_op(cid_a, cid_b, op)?; + + let changed = GlyphChangedEntities { + contour_ids: created_ids, + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn remove_points( + &mut self, + #[napi(ts_arg_type = "Array")] point_ids: Vec, + ) -> errors::Result { + let point_ids: BridgeResult> = point_ids.iter().map(|id| parse::(id)).collect(); + let point_ids = point_ids?; + + let session = self.active_session_mut()?; + session.remove_points(&point_ids)?; + + let changed = GlyphChangedEntities::points(point_ids); + let change = GlyphStructureChange::from_layer(session.layer(), changed); + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn toggle_smooth( + &mut self, + #[napi(ts_arg_type = "PointId")] point_id: String, + ) -> errors::Result { + let parsed_id = parse::(&point_id)?; + let session = self.active_session_mut()?; + session.toggle_smooth(parsed_id)?; + + let changed = GlyphChangedEntities { + point_ids: vec![parsed_id], + ..Default::default() + }; + + let change = GlyphStructureChange::from_layer(session.layer(), changed); + + self.mark_active_edit_changed(); + Ok(change.into()) + } + + /// Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. + /// Coords are interleaved [x0, y0, x1, y1, ...]. + #[napi] + pub fn set_positions( + &mut self, + point_ids: Option, + point_coords: Option, + anchor_ids: Option, + anchor_coords: Option, + ) -> errors::Result { + let session = self.active_session_mut()?; + let changed = session.set_bulk_node_positions(BulkNodePositionUpdates { + point_ids: point_ids.as_ref().map(|ids| { + let ids: &[u64] = ids; + ids + }), + point_coords: point_coords.as_ref().map(|coords| { + let coords: &[f64] = coords; + coords + }), + anchor_ids: anchor_ids.as_ref().map(|ids| { + let ids: &[u64] = ids; + ids + }), + anchor_coords: anchor_coords.as_ref().map(|coords| { + let coords: &[f64] = coords; + coords + }), + })?; + + let change = GlyphValueChange::from_layer(session.layer(), changed); + self.mark_active_edit_changed(); + Ok(change.into()) + } + + #[napi] + pub fn restore_state( + &mut self, + structure: NapiGlyphStructure, + values: Float64Array, + ) -> errors::Result { + let structure = GlyphStructure::from(structure); + let values: &[f64] = &values; + + let session = self.active_session_mut()?; + session.restore_layer(&structure, values)?; + + let change = GlyphStructureChange::from_layer(session.layer(), Default::default()); + self.mark_active_edit_changed(); + Ok(change.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shift_edit::{Contour, PointType}; + use std::time::{Duration, Instant}; + + fn glyph_handle(name: &str, unicode: Option) -> GlyphHandle { + GlyphHandle { + name: name.to_string(), + unicode, + } + } + + #[derive(Clone, Copy)] + struct PerfFontMark { + label: &'static str, + glyphs: usize, + contours_per_glyph: usize, + points_per_contour: usize, + } + + impl PerfFontMark { + fn total_points(self) -> usize { + self.glyphs * self.contours_per_glyph * self.points_per_contour + } + } + + fn point_heavy_layer(mark: PerfFontMark, glyph_index: usize) -> GlyphLayer { + let mut layer = GlyphLayer::with_width(500.0 + glyph_index as f64); + + for contour_index in 0..mark.contours_per_glyph { + let mut contour = Contour::new(); + for point_index in 0..mark.points_per_contour { + contour.add_point( + point_index as f64, + (glyph_index + contour_index + point_index) as f64, + PointType::OnCurve, + false, + ); + } + layer.add_contour(contour); + } + + layer + } + + fn point_heavy_glyph( + name: impl Into, + unicode: u32, + layer_id: LayerId, + mark: PerfFontMark, + ) -> Glyph { + let mut glyph = Glyph::with_unicode(name.into(), unicode); + glyph.set_layer(layer_id, point_heavy_layer(mark, unicode as usize)); + glyph + } + + fn point_heavy_font(mark: PerfFontMark) -> Font { + let mut font = Font::new(); + let default_layer_id = font.default_layer_id(); + + for glyph_index in 0..mark.glyphs { + font.insert_glyph(point_heavy_glyph( + format!("g{glyph_index:05}"), + glyph_index as u32, + default_layer_id, + mark, + )); + } + + font + } + + fn print_perf_mark(operation: &str, mark: PerfFontMark, elapsed: Duration) { + eprintln!( + "perf_mark {operation} [{}]: {} glyphs / {} points in {:?}", + mark.label, + mark.glyphs, + mark.total_points(), + elapsed + ); + } + + #[test] + fn new_bridge_exposes_empty_committed_font_state() { + let bridge = Bridge::new(); + + let metadata = bridge.get_metadata(); + let metrics = bridge.get_metrics(); + + assert!(!bridge.has_edit_session()); + assert_eq!(bridge.get_glyph_count(), 0); + assert!(bridge.get_glyphs().is_empty()); + assert_eq!(metadata.family_name.as_deref(), Some("Untitled Font")); + assert_eq!(metadata.style_name.as_deref(), Some("Regular")); + assert_eq!(metrics.units_per_em, 1000.0); + assert_eq!(metrics.ascender, 800.0); + assert_eq!(metrics.descender, -200.0); + } + + #[test] + fn edit_session_tracks_current_glyph() { + let mut bridge = Bridge::new(); + + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + + assert!(bridge.has_edit_session()); + assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); + assert_eq!(bridge.get_editing_unicode(), Some(65)); + } + + #[test] + fn end_edit_session_commits_glyph_to_font() { + let mut bridge = Bridge::new(); + + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + bridge.end_edit_session().unwrap(); + + let glyphs = bridge.get_glyphs(); + assert!(!bridge.has_edit_session()); + assert_eq!(glyphs.len(), 1); + assert_eq!(glyphs[0].name, "A"); + assert_eq!(glyphs[0].unicodes, vec![65]); + } + + #[test] + fn starting_second_session_returns_bridge_error() { + let mut bridge = Bridge::new(); + + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + let result = bridge.start_edit_session(glyph_handle("B", Some(66))); + + assert_eq!(result.unwrap_err().reason, "edit session already active"); + assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); + } + + #[test] + fn add_contour_returns_structure_change() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + + let change = bridge.add_contour().unwrap(); + + assert_eq!(change.structure.contours.len(), 1); + assert_eq!(change.changed.contour_ids.len(), 1); + assert_eq!( + change.structure.contours[0].id, + change.changed.contour_ids[0] + ); + assert!(change.structure.contours[0].points.is_empty()); + } + + #[test] + fn save_snapshot_includes_active_edit_without_committing_session() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + let point_id = bridge + .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .unwrap() + .changed + .point_ids[0] + .clone(); + + let snapshot = bridge.save_snapshot(); + let glyph = snapshot + .glyph("A") + .expect("snapshot should include active A"); + let layer = glyph + .layer(snapshot.default_layer_id()) + .expect("active glyph should include default layer"); + + assert!(bridge.has_edit_session()); + assert!(bridge.get_glyphs().is_empty()); + assert_eq!(glyph.unicodes(), &[65]); + assert_eq!(layer.contours().len(), 1); + assert_eq!( + layer.contours().values().next().unwrap().points()[0] + .id() + .to_string(), + point_id + ); + } + + #[test] + fn persisted_older_snapshot_keeps_document_dirty_after_new_edit() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + let snapshot = bridge.save_snapshot(); + + bridge + .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .unwrap(); + record_persisted_version(&bridge.persisted_version, snapshot.version()); + + assert_eq!(snapshot.version().as_u32(), 1); + assert_eq!(bridge.get_live_version(), 2); + assert_eq!(bridge.get_persisted_version(), 1); + assert!(bridge.is_dirty()); + } + + #[test] + fn load_resets_persisted_version_handle_for_old_async_saves() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + bridge.add_contour().unwrap(); + let old_persisted_version = bridge.persisted_version.clone(); + + bridge.font = Font::default(); + bridge.active_edit = None; + bridge.live_version = DocumentVersion::default(); + bridge.persisted_version = Arc::new(AtomicU64::new(0)); + record_persisted_version(&old_persisted_version, DocumentVersion(1)); + + assert_eq!(bridge.get_persisted_version(), 0); + assert!(!bridge.is_dirty()); + } + + #[test] + fn ending_dirty_edit_session_does_not_increment_version_again() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + bridge.add_contour().unwrap(); + + bridge.end_edit_session().unwrap(); + + assert_eq!(bridge.get_live_version(), 1); + assert!(bridge.is_dirty()); + assert_eq!(bridge.get_glyphs()[0].name, "A"); + } + + #[test] + fn add_point_returns_structure_and_changed_point() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + + let change = bridge + .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .unwrap(); + + let points = &change.structure.contours[0].points; + assert_eq!(change.changed.point_ids.len(), 1); + assert_eq!(points.len(), 1); + assert_eq!(points[0].id, change.changed.point_ids[0]); + assert_eq!(points[0].point_type, NapiPointType::OnCurve); + assert!(!points[0].smooth); + } + + #[test] + fn get_glyph_state_reads_active_edit_overlay() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65))) + .unwrap(); + let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); + bridge + .add_point(contour_id, 10.0, 20.0, NapiPointType::OnCurve, false) + .unwrap(); + + let state = bridge + .get_glyph_state(glyph_handle("A", Some(65))) + .expect("active edit glyph should be readable"); + + assert!(bridge.get_glyphs().is_empty()); + assert_eq!(state.structure.contours.len(), 1); + assert_eq!(state.structure.contours[0].points.len(), 1); + assert_eq!(&state.values[..], &[500.0, 10.0, 20.0]); + } + + #[test] + fn get_glyph_state_returns_none_for_missing_glyph() { + let bridge = Bridge::new(); + + assert!(bridge + .get_glyph_state(glyph_handle("missing", None)) + .is_none()); + } + + #[test] + fn edit_methods_require_active_session() { + let mut bridge = Bridge::new(); + + let result = bridge.add_contour(); + + assert_eq!(result.err().unwrap().reason, "no active edit"); + } + + #[test] + fn perf_mark_save_snapshot_setup_with_active_edit_overlay() { + let committed_mark = PerfFontMark { + label: "cjk-scale committed", + glyphs: 10_000, + contours_per_glyph: 2, + points_per_contour: 8, + }; + let active_mark = PerfFontMark { + label: "active-overlay", + glyphs: 1, + contours_per_glyph: 50, + points_per_contour: 1_000, + }; + let mut bridge = Bridge::new(); + bridge.font = point_heavy_font(committed_mark); + let default_layer_id = bridge.font.default_layer_id(); + let active_glyph = point_heavy_glyph("active", 0xE000, default_layer_id, active_mark); + bridge.active_edit = Some(ActiveEdit::from_glyph( + active_glyph, + default_layer_id, + Some(0xE000), + )); + bridge.mark_active_edit_changed(); + + let start = Instant::now(); + let snapshots: Vec<_> = (0..128).map(|_| bridge.save_snapshot()).collect(); + let elapsed = start.elapsed(); + + for snapshot in &snapshots { + let active_glyph = snapshot + .glyph("active") + .expect("snapshot should include the active edit overlay"); + let active_layer = active_glyph + .layer(snapshot.default_layer_id()) + .expect("active overlay should include the default layer"); + + assert_eq!(snapshot.version().as_u32(), bridge.get_live_version()); + assert_eq!(snapshot.glyphs().len(), committed_mark.glyphs + 1); + assert_eq!(active_glyph.unicodes(), &[0xE000]); + assert_eq!( + active_layer.contours().len(), + active_mark.contours_per_glyph + ); + } + assert!(bridge.has_edit_session()); + assert_eq!(bridge.get_glyph_count(), committed_mark.glyphs as u32); + + print_perf_mark( + "save_snapshot active overlay x128", + PerfFontMark { + label: "cjk-scale + active-overlay", + ..committed_mark + }, + elapsed, + ); + assert!( + elapsed < Duration::from_secs(1), + "active-overlay save snapshot setup should stay comfortably sub-second; got {elapsed:?}" + ); + } +} diff --git a/crates/shift-bridge/src/errors.rs b/crates/shift-bridge/src/errors.rs new file mode 100644 index 00000000..e3fa5c8e --- /dev/null +++ b/crates/shift-bridge/src/errors.rs @@ -0,0 +1,54 @@ +use napi::{Error, JsError, Status}; +use shift_backends::BackendError; +use shift_edit::error::CoreError; + +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("no active edit")] + NoActiveEdit, + + #[error("edit session already active")] + ActiveEditAlreadyExists, + + #[error("invalid {kind}: {value}")] + InvalidInput { kind: &'static str, value: String }, + + #[error(transparent)] + Core(#[from] CoreError), + + #[error(transparent)] + Backend(#[from] BackendError), +} + +pub fn to_napi_error(error: BridgeError) -> Error { + let status = match &error { + BridgeError::InvalidInput { .. } + | BridgeError::Backend(BackendError::MissingExtension) + | BridgeError::Backend(BackendError::InvalidPathUtf8) + | BridgeError::Backend(BackendError::InvalidExtensionUtf8) + | BridgeError::Backend(BackendError::UnsupportedFormat(_)) + | BridgeError::Backend(BackendError::UnsupportedWriteFormat(_)) => Status::InvalidArg, + BridgeError::NoActiveEdit + | BridgeError::ActiveEditAlreadyExists + | BridgeError::Core(_) + | BridgeError::Backend(_) => Status::GenericFailure, + }; + + Error::new(status, error.to_string()) +} + +impl From for Error { + fn from(error: BridgeError) -> Self { + to_napi_error(error) + } +} + +impl From for JsError { + fn from(error: BridgeError) -> Self { + JsError::from(to_napi_error(error)) + } +} + +pub type Result = std::result::Result; + +pub type BridgeResult = Result; diff --git a/crates/shift-bridge/src/input.rs b/crates/shift-bridge/src/input.rs new file mode 100644 index 00000000..82c2d790 --- /dev/null +++ b/crates/shift-bridge/src/input.rs @@ -0,0 +1,44 @@ +use std::str::FromStr; + +use shift_ir::{AnchorId, ComponentId, ContourId, GuidelineId, LayerId, PointId}; + +use crate::errors::{BridgeError, BridgeResult}; + +pub trait BridgeParse: Sized + FromStr { + const KIND: &'static str; + + fn parse_bridge(value: &str) -> BridgeResult { + value.parse().map_err(|_| BridgeError::InvalidInput { + kind: Self::KIND, + value: value.to_string(), + }) + } +} + +pub fn parse(value: &str) -> BridgeResult { + T::parse_bridge(value) +} + +impl BridgeParse for ContourId { + const KIND: &'static str = "contour ID"; +} + +impl BridgeParse for PointId { + const KIND: &'static str = "point ID"; +} + +impl BridgeParse for AnchorId { + const KIND: &'static str = "anchor ID"; +} + +impl BridgeParse for ComponentId { + const KIND: &'static str = "component ID"; +} + +impl BridgeParse for GuidelineId { + const KIND: &'static str = "guideline ID"; +} + +impl BridgeParse for LayerId { + const KIND: &'static str = "layer ID"; +} diff --git a/crates/shift-bridge/src/lib.rs b/crates/shift-bridge/src/lib.rs new file mode 100644 index 00000000..56e19475 --- /dev/null +++ b/crates/shift-bridge/src/lib.rs @@ -0,0 +1,3 @@ +mod bridge; +mod errors; +mod input; diff --git a/crates/shift-node/vitest.config.ts b/crates/shift-bridge/vitest.config.ts similarity index 100% rename from crates/shift-node/vitest.config.ts rename to crates/shift-bridge/vitest.config.ts diff --git a/crates/shift-core/docs/DOCS.md b/crates/shift-core/docs/DOCS.md deleted file mode 100644 index 756d7611..00000000 --- a/crates/shift-core/docs/DOCS.md +++ /dev/null @@ -1,118 +0,0 @@ -# shift-core - -Editing logic, composite resolution, and font I/O orchestration for the Shift font editor. - -## Architecture Invariants - -**Architecture Invariant:** `EditSession` operates on a `GlyphLayer`, not a `Glyph`. A session holds a single layer plus glyph metadata (name, unicode). This means multi-layer editing requires separate sessions per layer, and callers must extract/reinsert layers via `into_layer()`. WHY: Layers are the unit of editing in font design; glyphs are just named containers. Operating at the layer level avoids ambiguity about which layer is being mutated. - -**Architecture Invariant:** Core data types (`Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, entity IDs) live in `shift-ir`, not here. `shift-core` re-exports them from `shift_ir` for convenience. WHY: `shift-ir` is the canonical IR shared across backends; `shift-core` adds editing/resolution logic on top. If you need to change a data structure, change it in `shift-ir`. - -**CRITICAL:** `restore_from_snapshot` clears all contours and anchors before rebuilding from the snapshot. If the snapshot is stale or partial, geometry is silently lost. WHY: Snapshots are the undo/redo transport format -- they must represent complete state, not diffs. - -**Architecture Invariant:** Composite resolution is read-only. `flatten_component_contours_for_layer` and `resolve_component_instances_for_layer` produce derived `ResolvedContour` geometry with regenerated `PointId`s. They never mutate source glyphs. WHY: Resolved points are render-time artifacts, not editable identities. Mixing them with source IDs would corrupt selection state. - -**Architecture Invariant:** Component anchor attachment uses a stack-based "most recently placed" rule. `_top` in a mark component attaches to the latest `top` anchor seen so far in processing order. Components are processed in stable `ComponentId::raw` order, so anchor stacking is deterministic. WHY: This mirrors the OpenType mark-to-mark attachment model and ensures predictable composite glyph layout. - -**Architecture Invariant:** Snapshot types use `#[ts(export)]` and serialize to `packages/types/src/generated/`. They are the contract between Rust and TypeScript. Field names use `camelCase` via `#[serde(rename_all = "camelCase")]`. Adding/removing snapshot fields is a cross-language breaking change. WHY: ts-rs generates TypeScript interfaces at build time; any mismatch causes runtime deserialization failures on the JS side. - -## Codemap - -``` -src/ - lib.rs -- Re-exports from shift-ir and shift-backends; defines public API surface - edit_session.rs -- EditSession: mutable glyph-layer editing context (move/add/remove/paste/boolean ops) - snapshot.rs -- GlyphSnapshot, CommandResult, and related types for Rust-to-TypeScript serialization - composite.rs -- Composite glyph resolution: anchor attachment, component flattening, SVG path gen, bbox - dependency_graph.rs -- DependencyGraph: forward/reverse component dependency index - font_loader.rs -- FontLoader: pluggable format dispatch (UFO, Glyphs, TTF/OTF) - binary.rs -- BytesFontAdaptor (skrifa-based TTF/OTF loading), ShiftPen, compile_font (via fontc) - curve.rs -- Tight bounding box computation for line/quad/cubic curve segments - vec2.rs -- Vec2: 2D vector math utilities - constants.rs -- PIXEL, DEFAULT_X_ADVANCE constants -``` - -## Key Types - -- `EditSession` -- Mutable editing context wrapping a `GlyphLayer` with glyph metadata. All point/anchor/contour mutation goes through this. -- `NodeRef` -- Enum unifying `Point`, `Anchor`, and `Guideline` references for batch move/transform operations. -- `NodePositionUpdate` -- Absolute position update for a `NodeRef`, used by `set_node_positions`. -- `GlyphSnapshot` -- Serializable glyph state sent to TypeScript. Includes contours, anchors, composite contours, and active contour ID. -- `CommandResult` -- Standardized success/error response wrapping a `GlyphSnapshot` with undo/redo flags. -- `FontLoader` -- Format-dispatching font reader/writer using `FontAdaptor` trait objects. -- `GlyphLayerProvider` -- Trait for composite resolution layer lookup; `FontLayerProvider` is the default impl backed by `Font`. -- `DependencyGraph` -- Bidirectional component dependency index (`uses` / `used_by`) with transitive dependent query. -- `ResolvedContour` -- Derived contour from composite flattening, with regenerated point IDs. -- `Vec2` -- Lightweight 2D vector with arithmetic ops. - -## How it works - -### Editing flow - -1. Caller creates an `EditSession` from a glyph name, unicode, and `GlyphLayer`. -2. The session provides mutation methods: `add_point`, `move_points`, `move_nodes`, `transform_nodes`, `set_node_positions`, `apply_boolean_op`, `paste_contours`, etc. -3. `NodeRef` unifies points and anchors so batch operations (move, transform) work across entity types in a single call. -4. After editing, `GlyphSnapshot::from_edit_session` serializes the current state for the TypeScript UI. -5. For undo, `restore_from_snapshot` replaces all layer content with a previous snapshot. -6. The session is consumed via `into_layer()` to return the layer to the glyph. - -### Composite resolution - -1. `resolve_component_instances_for_layer` walks components in stable ID order. -2. For each component, it resolves the final transform: explicit affine matrix, then anchor-based offset if a `_name` anchor matches a previously placed `name` anchor. -3. `flatten_component_named` recurses into nested components. Cycles are handled branch-locally (cyclic branches are skipped; non-cyclic siblings still contribute geometry). -4. Resolved contours get new `PointId`s and are used for SVG path generation (`layer_to_svg_path`) and bounding box computation (`layer_bbox`). - -### Font loading - -`FontLoader` dispatches by file extension to `FontAdaptor` implementations: -- UFO -> `UfoFontAdaptor` (via `shift-backends::UfoReader`) -- .glyphs/.glyphspackage -> `GlyphsFontAdaptor` (via `shift-backends::GlyphsReader`) -- TTF/OTF -> `BytesFontAdaptor` (skrifa outline extraction with `ShiftPen`) - -Binary loading auto-detects smooth points using a collinearity heuristic (`SMOOTH_ANGLE_TOLERANCE` = ~2.9 degrees). Writing is only supported for UFO format. - -## Workflow recipes - -### Add a new editing operation to EditSession - -1. Add the method to `EditSession` in `edit_session.rs`. -2. If it returns results to JS, create or reuse a snapshot type in `snapshot.rs` with `#[derive(TS)]` and `#[ts(export)]`. -3. Wire the method through `shift-node` NAPI bindings. -4. Run `cargo test -p shift-core` and `cargo test -p shift-node`. - -### Add a new font format for reading - -1. Add a variant to `FontFormat` in `font_loader.rs`. -2. Implement `FontAdaptor` for it. -3. Register it in `FontLoader::new()`. -4. Add extension mapping in `format_from_extension`. - -### Add a new snapshot field - -1. Add the field to the snapshot struct in `snapshot.rs` with `#[serde(default)]` if it must be backwards-compatible. -2. Update `from_edit_session` (or the relevant `From` impl) to populate it. -3. If used in undo, update `restore_from_snapshot`. -4. Run `cargo test -p shift-core` then regenerate TS types. - -## Gotchas - -- `add_point` without an active contour silently creates a new contour and sets it active. This is intentional but can surprise callers who expect an error. -- `apply_boolean_op` removes both input contours even if the boolean operation produces zero output contours (valid for subtraction). -- `find_point_contour` does a linear scan across all contours. For hot paths with many points, prefer `set_node_positions` which iterates contours once. -- Binary font loading (`BytesFontAdaptor`) produces contours from skrifa's outline pen, which decomposes composites into simple outlines. Component structure is lost. -- `compile_font` delegates to `fontc` and writes to a build directory. It takes a `.ufo` source path, not a `Font` struct. -- `layer_complexity` is a heuristic (contours + components count). It picks the "primary" layer for rendering but may not match user expectations for sparse multi-layer glyphs. - -## Verification - -```bash -cargo test -p shift-core # Unit tests for all modules -cargo clippy -p shift-core # Lint check -``` - -## Related - -- `shift-ir` -- Canonical data model (`Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, entity IDs, `CurveSegment`) -- `shift-backends` -- Format-specific readers/writers (`UfoReader`, `UfoWriter`, `GlyphsReader`, `FontReader`, `FontWriter`) -- `shift-node` -- NAPI bridge that exposes `EditSession` and snapshot types to JavaScript via `FontEngine` diff --git a/crates/shift-core/src/constants.rs b/crates/shift-core/src/constants.rs deleted file mode 100644 index 8c1514ff..00000000 --- a/crates/shift-core/src/constants.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub const PIXEL: f64 = 1.0; -pub const DEFAULT_X_ADVANCE: f64 = 600.0; diff --git a/crates/shift-core/src/edit_session.rs b/crates/shift-core/src/edit_session.rs deleted file mode 100644 index 585d65e6..00000000 --- a/crates/shift-core/src/edit_session.rs +++ /dev/null @@ -1,1051 +0,0 @@ -use crate::{ - snapshot::GlyphSnapshot, Anchor, AnchorId, Contour, ContourId, GlyphLayer, GuidelineId, - PointId, PointType, Transform, -}; -use shift_ir::{boolean, BooleanOp}; -use std::collections::{HashMap, HashSet}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NodeRef { - Point(PointId), - Anchor(AnchorId), - Guideline(GuidelineId), -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct NodePositionUpdate { - pub node: NodeRef, - pub x: f64, - pub y: f64, -} - -pub struct EditSession { - layer: GlyphLayer, - glyph_name: String, - unicode: u32, - active_contour_id: Option, -} - -impl EditSession { - pub fn new(name: String, unicode: u32, layer: GlyphLayer) -> Self { - Self { - layer, - glyph_name: name, - unicode, - active_contour_id: None, - } - } - - pub fn layer(&self) -> &GlyphLayer { - &self.layer - } - - pub fn layer_mut(&mut self) -> &mut GlyphLayer { - &mut self.layer - } - - pub fn into_layer(self) -> GlyphLayer { - self.layer - } - - pub fn glyph_name(&self) -> &str { - &self.glyph_name - } - - pub fn unicode(&self) -> u32 { - self.unicode - } - - pub fn width(&self) -> f64 { - self.layer.width() - } - - pub fn active_contour_id(&self) -> Option { - self.active_contour_id - } - - pub fn set_x_advance(&mut self, width: f64) { - self.layer.set_width(width); - } - - /// Translate all editable glyph geometry in the active layer. - /// - /// This moves contour points, anchors, and component transforms. - /// Glyph advance width is intentionally left unchanged. - pub fn translate_layer(&mut self, dx: f64, dy: f64) { - let contour_ids: Vec<_> = self.layer.contours().keys().cloned().collect(); - for contour_id in contour_ids { - if let Some(contour) = self.layer.contour_mut(contour_id) { - for point in contour.points_mut() { - point.translate(dx, dy); - } - } - } - - let anchor_ids: Vec<_> = self - .layer - .anchors_iter() - .map(|anchor| anchor.id()) - .collect(); - self.layer.move_anchors(&anchor_ids, dx, dy); - - let component_ids: Vec<_> = self.layer.components().keys().cloned().collect(); - for component_id in component_ids { - if let Some(mut component) = self.layer.remove_component(component_id) { - component.translate(dx, dy); - self.layer.add_component(component); - } - } - } - - pub fn set_active_contour(&mut self, contour_id: ContourId) { - self.active_contour_id = Some(contour_id); - } - - pub fn clear_active_contour(&mut self) { - self.active_contour_id = None; - } - - pub fn add_empty_contour(&mut self) -> ContourId { - let contour = Contour::new(); - let contour_id = contour.id(); - self.layer.add_contour(contour); - self.active_contour_id = Some(contour_id); - contour_id - } - - pub fn remove_contour(&mut self, contour_id: ContourId) -> Option { - if self.active_contour_id == Some(contour_id) { - self.active_contour_id = None; - } - self.layer.remove_contour(contour_id) - } - - fn contour_mut_or_err(&mut self, id: ContourId) -> Result<&mut Contour, String> { - self.layer - .contour_mut(id) - .ok_or_else(|| format!("Contour {id:?} not found")) - } - - pub fn add_point_to_contour( - &mut self, - contour_id: ContourId, - x: f64, - y: f64, - point_type: PointType, - is_smooth: bool, - ) -> Result { - let contour = self.contour_mut_or_err(contour_id)?; - let point_id = contour.add_point(x, y, point_type, is_smooth); - Ok(point_id) - } - - pub fn add_point( - &mut self, - x: f64, - y: f64, - point_type: PointType, - is_smooth: bool, - ) -> Result { - let contour_id = match self.active_contour_id { - Some(id) => id, - None => self.add_empty_contour(), - }; - self.add_point_to_contour(contour_id, x, y, point_type, is_smooth) - } - - pub fn toggle_smooth(&mut self, point_id: PointId) -> Result<(), String> { - let contour_id = self - .find_point_contour(point_id) - .ok_or_else(|| format!("Point {point_id:?} not found in any contour"))?; - - let contour = self.contour_mut_or_err(contour_id)?; - let point = contour - .get_point_mut(point_id) - .ok_or_else(|| format!("Point {point_id:?} not found in contour"))?; - - point.toggle_smooth(); - Ok(()) - } - - pub fn insert_point_before( - &mut self, - before_id: PointId, - x: f64, - y: f64, - point_type: PointType, - is_smooth: bool, - ) -> Result { - let contour_id = self - .find_point_contour(before_id) - .ok_or_else(|| format!("Point {before_id:?} not found in any contour"))?; - - let contour = self.contour_mut_or_err(contour_id)?; - contour - .insert_point_before(before_id, x, y, point_type, is_smooth) - .ok_or_else(|| format!("Failed to insert point before {before_id:?}")) - } - - pub fn move_point( - &mut self, - contour_id: ContourId, - point_id: PointId, - x: f64, - y: f64, - ) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - let point = contour - .get_point_mut(point_id) - .ok_or_else(|| format!("Point {point_id:?} not found"))?; - point.set_position(x, y); - Ok(()) - } - - pub fn translate_point( - &mut self, - contour_id: ContourId, - point_id: PointId, - dx: f64, - dy: f64, - ) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - let point = contour - .get_point_mut(point_id) - .ok_or_else(|| format!("Point {point_id:?} not found"))?; - point.translate(dx, dy); - Ok(()) - } - - pub fn remove_point(&mut self, contour_id: ContourId, point_id: PointId) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - contour - .remove_point(point_id) - .ok_or_else(|| format!("Point {point_id:?} not found"))?; - Ok(()) - } - - pub fn close_contour(&mut self, contour_id: ContourId) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - contour.close(); - Ok(()) - } - - pub fn open_contour(&mut self, contour_id: ContourId) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - contour.open(); - Ok(()) - } - - pub fn reverse_contour(&mut self, contour_id: ContourId) -> Result<(), String> { - let contour = self.contour_mut_or_err(contour_id)?; - contour.reverse(); - Ok(()) - } - - pub fn apply_boolean_op( - &mut self, - contour_id_a: ContourId, - contour_id_b: ContourId, - op: BooleanOp, - ) -> Result, String> { - let a = self - .layer - .contour(contour_id_a) - .ok_or_else(|| format!("Contour {contour_id_a:?} not found"))? - .clone(); - let b = self - .layer - .contour(contour_id_b) - .ok_or_else(|| format!("Contour {contour_id_b:?} not found"))? - .clone(); - - let result = boolean(op, &a, &b).map_err(|e| format!("Boolean operation failed: {e}"))?; - - self.remove_contour(contour_id_a); - self.remove_contour(contour_id_b); - - let mut created_ids = Vec::new(); - for contour in result.0 { - let id = self.layer.add_contour(contour); - created_ids.push(id); - } - - Ok(created_ids) - } - - pub fn find_point_contour(&self, point_id: PointId) -> Option { - for contour in self.layer.contours_iter() { - if contour.get_point(point_id).is_some() { - return Some(contour.id()); - } - } - None - } - - pub fn move_points(&mut self, point_ids: &[PointId], dx: f64, dy: f64) -> Vec { - if point_ids.is_empty() { - return Vec::new(); - } - - let target_ids: HashSet = point_ids.iter().copied().collect(); - let contour_ids: Vec<_> = self.layer.contours().keys().copied().collect(); - let mut moved_points = Vec::with_capacity(point_ids.len()); - - for contour_id in contour_ids { - if let Some(contour) = self.layer.contour_mut(contour_id) { - for point in contour.points_mut() { - let point_id = point.id(); - if target_ids.contains(&point_id) { - point.translate(dx, dy); - moved_points.push(point_id); - } - } - } - } - - moved_points - } - - pub fn transform_points( - &mut self, - point_ids: &[PointId], - transform: Transform, - ) -> Vec { - if point_ids.is_empty() { - return Vec::new(); - } - - let target_ids: HashSet = point_ids.iter().copied().collect(); - let contour_ids: Vec<_> = self.layer.contours().keys().copied().collect(); - let mut moved_points = Vec::with_capacity(point_ids.len()); - - for contour_id in contour_ids { - if let Some(contour) = self.layer.contour_mut(contour_id) { - for point in contour.points_mut() { - let point_id = point.id(); - if !target_ids.contains(&point_id) { - continue; - } - - let (x, y) = transform.transform_point(point.x(), point.y()); - point.set_position(x, y); - moved_points.push(point_id); - } - } - } - - moved_points - } - - /// Set absolute position for a single point - pub fn set_point_position(&mut self, point_id: PointId, x: f64, y: f64) -> bool { - let Some(contour_id) = self.find_point_contour(point_id) else { - return false; - }; - let Some(contour) = self.layer.contour_mut(contour_id) else { - return false; - }; - let Some(point) = contour.get_point_mut(point_id) else { - return false; - }; - point.set_position(x, y); - true - } - - /// Set absolute position for a single anchor - pub fn set_anchor_position(&mut self, anchor_id: AnchorId, x: f64, y: f64) -> bool { - self.layer.set_anchor_position(anchor_id, x, y) - } - - pub fn remove_points(&mut self, point_ids: &[PointId]) -> Vec { - let mut removed_points = Vec::new(); - - let point_contours: Vec<(PointId, ContourId)> = point_ids - .iter() - .filter_map(|&pid| self.find_point_contour(pid).map(|cid| (pid, cid))) - .collect(); - - for (point_id, contour_id) in point_contours { - if let Some(contour) = self.layer.contour_mut(contour_id) { - if contour.remove_point(point_id).is_some() { - removed_points.push(point_id); - } - } - } - - removed_points - } - - pub fn move_anchors(&mut self, anchor_ids: &[AnchorId], dx: f64, dy: f64) -> Vec { - self.layer.move_anchors(anchor_ids, dx, dy) - } - - pub fn transform_anchors( - &mut self, - anchor_ids: &[AnchorId], - transform: Transform, - ) -> Vec { - let mut moved_anchors = Vec::with_capacity(anchor_ids.len()); - - for anchor_id in anchor_ids { - let Some(anchor) = self.layer.anchor_mut(*anchor_id) else { - continue; - }; - - let (x, y) = transform.transform_point(anchor.x(), anchor.y()); - anchor.set_position(x, y); - moved_anchors.push(*anchor_id); - } - - moved_anchors - } - - pub fn move_nodes(&mut self, nodes: &[NodeRef], dx: f64, dy: f64) -> Vec { - let mut point_ids = Vec::new(); - let mut anchor_ids = Vec::new(); - - for node in nodes { - match node { - NodeRef::Point(point_id) => point_ids.push(*point_id), - NodeRef::Anchor(anchor_id) => anchor_ids.push(*anchor_id), - // Placeholder for guideline editing support. - NodeRef::Guideline(_guideline_id) => {} - } - } - - let moved_points = self.move_points(&point_ids, dx, dy); - if !anchor_ids.is_empty() { - self.layer.move_anchors(&anchor_ids, dx, dy); - } - moved_points - } - - pub fn transform_nodes(&mut self, nodes: &[NodeRef], transform: Transform) -> Vec { - let mut point_ids = Vec::new(); - let mut anchor_ids = Vec::new(); - - for node in nodes { - match node { - NodeRef::Point(point_id) => point_ids.push(*point_id), - NodeRef::Anchor(anchor_id) => anchor_ids.push(*anchor_id), - NodeRef::Guideline(_guideline_id) => {} - } - } - - let moved_points = self.transform_points(&point_ids, transform); - if !anchor_ids.is_empty() { - self.transform_anchors(&anchor_ids, transform); - } - moved_points - } - - pub fn set_node_positions(&mut self, updates: &[NodePositionUpdate]) -> bool { - let mut point_updates = HashMap::new(); - let mut anchor_updates = HashMap::new(); - for update in updates { - match update.node { - NodeRef::Point(point_id) => { - point_updates.insert(point_id, (update.x, update.y)); - } - NodeRef::Anchor(anchor_id) => { - anchor_updates.insert(anchor_id, (update.x, update.y)); - } - NodeRef::Guideline(_guideline_id) => {} - } - } - - let mut updated_any = false; - if !point_updates.is_empty() { - let contour_ids: Vec<_> = self.layer.contours().keys().copied().collect(); - for contour_id in contour_ids { - if let Some(contour) = self.layer.contour_mut(contour_id) { - for point in contour.points_mut() { - if let Some((x, y)) = point_updates.get(&point.id()) { - point.set_position(*x, *y); - updated_any = true; - } - } - } - } - } - - for (anchor_id, (x, y)) in anchor_updates { - if self.set_anchor_position(anchor_id, x, y) { - updated_any = true; - } - } - - updated_any - } - - pub fn contour(&self, id: ContourId) -> Option<&Contour> { - self.layer.contour(id) - } - - pub fn contour_mut(&mut self, id: ContourId) -> Option<&mut Contour> { - self.layer.contour_mut(id) - } - - pub fn contours_iter(&self) -> impl Iterator { - self.layer.contours_iter() - } - - pub fn contours_count(&self) -> usize { - self.layer.contours().len() - } - - pub fn paste_contours( - &mut self, - contours: Vec, - offset_x: f64, - offset_y: f64, - ) -> PasteResult { - let mut created_point_ids = Vec::new(); - let mut created_contour_ids = Vec::new(); - - for paste_contour in contours { - let mut contour = Contour::new(); - - for point in paste_contour.points { - let point_id = contour.add_point( - point.x + offset_x, - point.y + offset_y, - point.point_type, - point.smooth, - ); - created_point_ids.push(point_id); - } - - if paste_contour.closed { - contour.close(); - } - - let contour_id = self.layer.add_contour(contour); - created_contour_ids.push(contour_id); - } - - PasteResult { - success: true, - created_point_ids, - created_contour_ids, - error: None, - } - } - - pub fn restore_from_snapshot(&mut self, snapshot: &GlyphSnapshot) { - self.layer.clear_contours(); - self.layer.clear_anchors(); - - let active_contour_id = snapshot - .active_contour_id - .as_ref() - .and_then(|id_str| id_str.parse::().ok()); - - self.set_x_advance(snapshot.x_advance); - - for contour_snapshot in &snapshot.contours { - let contour_id = contour_snapshot.id.parse::().ok(); - - let mut contour = match contour_id { - Some(id) => Contour::with_id(id), - None => Contour::new(), - }; - - for point_snapshot in &contour_snapshot.points { - let point_type = match point_snapshot.point_type { - crate::snapshot::PointType::OnCurve => PointType::OnCurve, - crate::snapshot::PointType::OffCurve => PointType::OffCurve, - }; - - let point_id = point_snapshot.id.parse::().ok(); - - match point_id { - Some(id) => contour.add_point_with_id( - id, - point_snapshot.x, - point_snapshot.y, - point_type, - point_snapshot.smooth, - ), - None => { - contour.add_point( - point_snapshot.x, - point_snapshot.y, - point_type, - point_snapshot.smooth, - ); - } - } - } - - if contour_snapshot.closed { - contour.close(); - } - - self.layer.add_contour(contour); - } - - for anchor_snapshot in &snapshot.anchors { - let anchor = match anchor_snapshot.id.parse::() { - Ok(id) => Anchor::with_id( - id, - anchor_snapshot.name.clone(), - anchor_snapshot.x, - anchor_snapshot.y, - ), - Err(_) => Anchor::new( - anchor_snapshot.name.clone(), - anchor_snapshot.x, - anchor_snapshot.y, - ), - }; - - self.layer.add_anchor(anchor); - } - - self.active_contour_id = active_contour_id; - } -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PastePoint { - pub x: f64, - pub y: f64, - pub point_type: PointType, - pub smooth: bool, -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasteContour { - pub points: Vec, - pub closed: bool, -} - -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PasteResult { - pub success: bool, - pub created_point_ids: Vec, - pub created_contour_ids: Vec, - pub error: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use shift_ir::Component; - - fn create_session() -> EditSession { - EditSession::new("test".to_string(), 65, GlyphLayer::with_width(500.0)) - } - - #[test] - fn new_session_has_no_active_contour() { - let session = create_session(); - assert!(session.active_contour_id().is_none()); - } - - #[test] - fn add_empty_contour_sets_active() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - assert_eq!(session.active_contour_id(), Some(contour_id)); - assert_eq!(session.contours_count(), 1); - } - - #[test] - fn remove_active_contour_clears_active() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - assert_eq!(session.active_contour_id(), Some(contour_id)); - - session.remove_contour(contour_id); - - assert!(session.active_contour_id().is_none()); - assert_eq!(session.contours_count(), 0); - } - - #[test] - fn move_nodes_moves_points_and_anchors() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - let point_id = session - .add_point_to_contour(contour_id, 10.0, 20.0, PointType::OnCurve, false) - .unwrap(); - let anchor_id = - session - .layer_mut() - .add_anchor(Anchor::new(Some("top".to_string()), 100.0, 200.0)); - - let moved_points = session.move_nodes( - &[NodeRef::Point(point_id), NodeRef::Anchor(anchor_id)], - 5.0, - -10.0, - ); - - assert_eq!(moved_points, vec![point_id]); - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - assert_eq!(point.x(), 15.0); - assert_eq!(point.y(), 10.0); - - let anchor = session.layer().anchor(anchor_id).unwrap(); - assert_eq!(anchor.x(), 105.0); - assert_eq!(anchor.y(), 190.0); - } - - #[test] - fn transform_nodes_applies_affine_transform_to_points_and_anchors() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - let point_id = session - .add_point_to_contour(contour_id, 10.0, 20.0, PointType::OnCurve, false) - .unwrap(); - let anchor_id = - session - .layer_mut() - .add_anchor(Anchor::new(Some("top".to_string()), 100.0, 200.0)); - - let moved_points = session.transform_nodes( - &[NodeRef::Point(point_id), NodeRef::Anchor(anchor_id)], - Transform { - xx: 2.0, - xy: 0.0, - yx: 0.0, - yy: 3.0, - dx: 5.0, - dy: -10.0, - }, - ); - - assert_eq!(moved_points, vec![point_id]); - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - assert_eq!(point.x(), 25.0); - assert_eq!(point.y(), 50.0); - - let anchor = session.layer().anchor(anchor_id).unwrap(); - assert_eq!(anchor.x(), 205.0); - assert_eq!(anchor.y(), 590.0); - } - - #[test] - fn set_node_positions_sets_points_and_anchors() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - let point_id = session - .add_point_to_contour(contour_id, 10.0, 20.0, PointType::OnCurve, false) - .unwrap(); - let anchor_id = - session - .layer_mut() - .add_anchor(Anchor::new(Some("bottom".to_string()), 100.0, 200.0)); - - let changed = session.set_node_positions(&[ - NodePositionUpdate { - node: NodeRef::Point(point_id), - x: 300.0, - y: 400.0, - }, - NodePositionUpdate { - node: NodeRef::Anchor(anchor_id), - x: 500.0, - y: 600.0, - }, - ]); - - assert!(changed); - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - assert_eq!(point.x(), 300.0); - assert_eq!(point.y(), 400.0); - - let anchor = session.layer().anchor(anchor_id).unwrap(); - assert_eq!(anchor.x(), 500.0); - assert_eq!(anchor.y(), 600.0); - } - - #[test] - fn add_point_to_active_contour() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - let point_id = session - .add_point(100.0, 200.0, PointType::OnCurve, false) - .unwrap(); - - let contour = session.contour(contour_id).unwrap(); - let point = contour.get_point(point_id).unwrap(); - - assert_eq!(point.x(), 100.0); - assert_eq!(point.y(), 200.0); - } - - #[test] - fn add_point_without_active_contour_creates_one() { - let mut session = create_session(); - assert!(session.active_contour_id().is_none()); - - let result = session.add_point(100.0, 200.0, PointType::OnCurve, false); - - assert!(result.is_ok()); - assert!(session.active_contour_id().is_some()); - assert_eq!(session.contours_count(), 1); - - let contour_id = session.active_contour_id().unwrap(); - let point_id = result.unwrap(); - let contour = session.contour(contour_id).unwrap(); - let point = contour.get_point(point_id).unwrap(); - assert_eq!(point.x(), 100.0); - assert_eq!(point.y(), 200.0); - } - - #[test] - fn move_point() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - let point_id = session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - - session - .move_point(contour_id, point_id, 50.0, 75.0) - .unwrap(); - - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - - assert_eq!(point.x(), 50.0); - assert_eq!(point.y(), 75.0); - } - - #[test] - fn translate_point() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - let point_id = session - .add_point(10.0, 20.0, PointType::OnCurve, false) - .unwrap(); - - session - .translate_point(contour_id, point_id, 5.0, -10.0) - .unwrap(); - - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - - assert_eq!(point.x(), 15.0); - assert_eq!(point.y(), 10.0); - } - - #[test] - fn into_layer_transfers_ownership() { - let mut session = create_session(); - session.add_empty_contour(); - - let layer = session.into_layer(); - - assert_eq!(layer.contours().len(), 1); - } - - #[test] - fn move_points_multiple() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - let p1 = session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - let p2 = session - .add_point(100.0, 100.0, PointType::OnCurve, false) - .unwrap(); - - let moved = session.move_points(&[p1, p2], 10.0, 20.0); - - assert_eq!(moved.len(), 2); - assert!(moved.contains(&p1)); - assert!(moved.contains(&p2)); - - let contour = session.contour(contour_id).unwrap(); - assert_eq!(contour.get_point(p1).unwrap().x(), 10.0); - assert_eq!(contour.get_point(p1).unwrap().y(), 20.0); - assert_eq!(contour.get_point(p2).unwrap().x(), 110.0); - assert_eq!(contour.get_point(p2).unwrap().y(), 120.0); - } - - #[test] - fn move_points_across_contours() { - let mut session = create_session(); - let c1_id = session.add_empty_contour(); - let p1 = session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - - let c2_id = session.add_empty_contour(); - let p2 = session - .add_point(50.0, 50.0, PointType::OnCurve, false) - .unwrap(); - - let moved = session.move_points(&[p1, p2], 5.0, 5.0); - - assert_eq!(moved.len(), 2); - - let c1 = session.contour(c1_id).unwrap(); - let c2 = session.contour(c2_id).unwrap(); - - assert_eq!(c1.get_point(p1).unwrap().x(), 5.0); - assert_eq!(c2.get_point(p2).unwrap().x(), 55.0); - } - - #[test] - fn translate_layer_moves_points_and_anchors_without_changing_width() { - let mut session = create_session(); - let original_width = session.width(); - let contour_id = session.add_empty_contour(); - let point_id = session - .add_point(10.0, 20.0, PointType::OnCurve, false) - .unwrap(); - let anchor_id = - session - .layer_mut() - .add_anchor(Anchor::new(Some("top".to_string()), 30.0, 40.0)); - - session.translate_layer(5.0, -3.0); - - let point = session - .contour(contour_id) - .unwrap() - .get_point(point_id) - .unwrap(); - let anchor = session.layer().anchor(anchor_id).unwrap(); - assert_eq!(point.x(), 15.0); - assert_eq!(point.y(), 17.0); - assert_eq!(anchor.x(), 35.0); - assert_eq!(anchor.y(), 37.0); - assert_eq!(session.width(), original_width); - } - - #[test] - fn translate_layer_moves_component_transforms() { - let mut session = create_session(); - let component_id = session - .layer_mut() - .add_component(Component::new("base".to_string())); - - session.translate_layer(12.0, -7.0); - - let component = session.layer().component(component_id).unwrap(); - let matrix = component.matrix(); - assert_eq!(matrix.dx, 12.0); - assert_eq!(matrix.dy, -7.0); - } - - #[test] - fn remove_points_multiple() { - let mut session = create_session(); - session.add_empty_contour(); - let p1 = session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - let p2 = session - .add_point(100.0, 100.0, PointType::OnCurve, false) - .unwrap(); - let p3 = session - .add_point(200.0, 200.0, PointType::OnCurve, false) - .unwrap(); - - let removed = session.remove_points(&[p1, p3]); - - assert_eq!(removed.len(), 2); - assert!(removed.contains(&p1)); - assert!(removed.contains(&p3)); - - assert!(session.find_point_contour(p2).is_some()); - assert!(session.find_point_contour(p1).is_none()); - assert!(session.find_point_contour(p3).is_none()); - } - - #[test] - fn insert_point_before_creates_bezier_pattern() { - let mut session = create_session(); - let contour_id = session.add_empty_contour(); - - let anchor1 = session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - let anchor2 = session - .add_point(100.0, 100.0, PointType::OnCurve, false) - .unwrap(); - - let control = session - .insert_point_before(anchor2, 50.0, 75.0, PointType::OffCurve, false) - .unwrap(); - - let contour = session.contour(contour_id).unwrap(); - let points: Vec<_> = contour.points().iter().collect(); - - assert_eq!(points.len(), 3); - assert_eq!(points[0].id(), anchor1); - assert_eq!(points[1].id(), control); - assert_eq!(points[2].id(), anchor2); - - assert_eq!(points[0].point_type(), PointType::OnCurve); - assert_eq!(points[1].point_type(), PointType::OffCurve); - assert_eq!(points[2].point_type(), PointType::OnCurve); - } - - #[test] - fn insert_point_before_nonexistent_fails() { - let mut session = create_session(); - session.add_empty_contour(); - session - .add_point(0.0, 0.0, PointType::OnCurve, false) - .unwrap(); - - let fake_id = PointId::new(); - let result = session.insert_point_before(fake_id, 50.0, 50.0, PointType::OffCurve, false); - - assert!(result.is_err()); - } - - #[test] - fn paste_point_deserializes_camel_case() { - let json = r#"{"x":100,"y":200,"pointType":"onCurve","smooth":false}"#; - let point: PastePoint = serde_json::from_str(json).unwrap(); - assert_eq!(point.x, 100.0); - assert_eq!(point.y, 200.0); - assert_eq!(point.point_type, PointType::OnCurve); - assert!(!point.smooth); - } - - #[test] - fn paste_contour_deserializes_camel_case() { - let json = - r#"{"points":[{"x":10,"y":20,"pointType":"offCurve","smooth":true}],"closed":true}"#; - let contour: PasteContour = serde_json::from_str(json).unwrap(); - assert_eq!(contour.points.len(), 1); - assert_eq!(contour.points[0].point_type, PointType::OffCurve); - assert!(contour.closed); - } -} diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs deleted file mode 100644 index 6f47c21f..00000000 --- a/crates/shift-core/src/interpolation.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::str::FromStr; - -use fontdrasil::coords::NormalizedLocation; -use fontdrasil::types::Tag; -use fontdrasil::variations::VariationModel; -use serde::{Deserialize, Serialize}; -use shift_ir::variation::to_fd_location; -use shift_ir::Axis; -use ts_rs::TS; - -use crate::snapshot::{AnchorSnapshot, ContourSnapshot, GlyphGeometry, MasterSnapshot}; -use crate::{Font, Glyph}; - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct AxisTent { - pub axis_tag: String, - pub lower: f64, - pub peak: f64, - pub upper: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct GlyphVariationData { - /// One entry per region. Inner = tents on the axes the region depends on. - pub regions: Vec>, - /// Same length as `regions`. Each entry = flat values matching `flatten()` order: - /// [xAdvance, p0.x, p0.y, ..., a0.x, a0.y, ...]. - pub deltas: Vec>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct SourceError { - #[ts(type = "number")] - pub source_index: usize, - pub source_name: String, - pub message: String, -} - -fn flatten(geom: &GlyphGeometry) -> Vec { - let mut values = vec![geom.x_advance]; - for contour in &geom.contours { - for point in &contour.points { - values.push(point.x); - values.push(point.y); - } - } - for anchor in &geom.anchors { - values.push(anchor.x); - values.push(anchor.y); - } - values -} - -fn check_compatibility(a: &GlyphGeometry, b: &GlyphGeometry) -> Result<(), String> { - if a.contours.len() != b.contours.len() { - return Err(format!( - "contour count mismatch: {} vs {}", - a.contours.len(), - b.contours.len() - )); - } - for (i, (ca, cb)) in a.contours.iter().zip(b.contours.iter()).enumerate() { - if ca.points.len() != cb.points.len() { - return Err(format!( - "contour {} point count mismatch: {} vs {}", - i, - ca.points.len(), - cb.points.len() - )); - } - } - if a.anchors.len() != b.anchors.len() { - return Err(format!( - "anchor count mismatch: {} vs {}", - a.anchors.len(), - b.anchors.len() - )); - } - Ok(()) -} - -/// Build per-master snapshots of a single glyph. -/// -/// Pure: no editing-session knowledge. Caller passes the `Glyph` it wants the -/// snapshots from (could be the disk copy or an in-progress editing copy with -/// the live session layer patched in — `shift-node` handles that detour). -/// -/// Returns `None` if the font isn't variable or no source has a non-empty layer -/// for this glyph. -pub fn build_master_snapshots(font: &Font, glyph: &Glyph) -> Option> { - if !font.is_variable() { - return None; - } - - let default_source_id = font.default_source_id(); - let mut masters: Vec = Vec::new(); - - for source in font.sources() { - let layer = match glyph.layer(source.layer_id()) { - Some(l) if !l.contours().is_empty() => l, - _ => continue, - }; - - let contours: Vec = layer - .contours() - .values() - .filter(|c| !c.points().is_empty()) - .map(ContourSnapshot::from) - .collect(); - - let anchors: Vec = layer.anchors_iter().map(AnchorSnapshot::from).collect(); - - masters.push(MasterSnapshot { - source_id: source.id().raw().to_string(), - source_name: source.name().to_string(), - is_default_source: default_source_id == Some(source.id()), - location: source.location().clone(), - geometry: GlyphGeometry { - x_advance: layer.width(), - contours, - anchors, - }, - }); - } - - if masters.is_empty() { - None - } else { - Some(masters) - } -} - -pub fn get_glyph_variation_data( - masters: &[MasterSnapshot], - axes: &[Axis], -) -> Option { - let ordered_axes: Vec = axes - .iter() - .filter_map(|a| Tag::from_str(a.tag()).ok()) - .collect(); - - let default_master = masters.iter().find(|master| master.is_default_source)?; - - let mut errors = Vec::new(); - let mut points: HashMap> = HashMap::new(); - for (source_index, master) in masters.iter().enumerate() { - match check_compatibility(&master.geometry, &default_master.geometry) { - Ok(()) => { - let loc = to_fd_location(&master.location, axes); - points.insert(loc, flatten(&master.geometry)); - } - Err(message) => { - errors.push(SourceError { - source_index, - source_name: master.source_name.clone(), - message, - }); - } - } - } - - let locations_set: HashSet = points.keys().cloned().collect(); - let model = VariationModel::new(locations_set, ordered_axes); - let model_deltas = model.deltas::(&points).ok()?; - - let regions: Vec> = model_deltas - .iter() - .map(|(region, _)| { - region - .iter() - .map(|(tag, tent)| AxisTent { - axis_tag: tag.to_string(), - lower: tent.min.into_inner().into_inner(), - peak: tent.peak.into_inner().into_inner(), - upper: tent.max.into_inner().into_inner(), - }) - .collect() - }) - .collect(); - - let deltas: Vec> = model_deltas.into_iter().map(|(_, d)| d).collect(); - - Some(GlyphVariationData { regions, deltas }) -} diff --git a/crates/shift-core/src/snapshot.rs b/crates/shift-core/src/snapshot.rs deleted file mode 100644 index ea0ab848..00000000 --- a/crates/shift-core/src/snapshot.rs +++ /dev/null @@ -1,381 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use crate::{ - edit_session::EditSession, Anchor, Contour, Location, Point, PointId, PointType as IrPointType, -}; -use shift_ir::component::{Component as IrComponent, DecomposedTransform}; - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct Component { - pub base_glyph_name: String, - pub transform: DecomposedTransform, -} - -impl From<&IrComponent> for Component { - fn from(component: &IrComponent) -> Self { - Self { - base_glyph_name: component.base_glyph().clone(), - transform: *component.transform(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct GlyphData { - pub geometry: GlyphGeometry, - pub variation_data: Option, - pub components: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct PointSnapshot { - #[ts(type = "PointId")] - pub id: String, - pub x: f64, - pub y: f64, - pub point_type: PointType, - pub smooth: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub enum PointType { - OnCurve, - OffCurve, -} - -impl From for PointType { - fn from(pt: IrPointType) -> Self { - match pt { - IrPointType::OnCurve => PointType::OnCurve, - IrPointType::OffCurve => PointType::OffCurve, - IrPointType::QCurve => PointType::OnCurve, - } - } -} - -impl From<&Point> for PointSnapshot { - fn from(point: &Point) -> Self { - Self { - id: point.id().raw().to_string(), - x: point.x(), - y: point.y(), - point_type: point.point_type().into(), - smooth: point.is_smooth(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct ContourSnapshot { - #[ts(type = "ContourId")] - pub id: String, - pub points: Vec, - pub closed: bool, -} - -impl From<&Contour> for ContourSnapshot { - fn from(contour: &Contour) -> Self { - Self { - id: contour.id().raw().to_string(), - points: contour.points().iter().map(PointSnapshot::from).collect(), - closed: contour.is_closed(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct AnchorSnapshot { - #[ts(type = "AnchorId")] - pub id: String, - pub name: Option, - pub x: f64, - pub y: f64, -} - -impl From<&Anchor> for AnchorSnapshot { - fn from(anchor: &Anchor) -> Self { - Self { - id: anchor.id().raw().to_string(), - name: anchor.name().map(|name| name.to_string()), - x: anchor.x(), - y: anchor.y(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct RenderPointSnapshot { - pub x: f64, - pub y: f64, - pub point_type: PointType, - pub smooth: bool, -} - -impl From<&Point> for RenderPointSnapshot { - fn from(point: &Point) -> Self { - Self { - x: point.x(), - y: point.y(), - point_type: point.point_type().into(), - smooth: point.is_smooth(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct RenderContourSnapshot { - pub points: Vec, - pub closed: bool, -} - -impl From<&Contour> for RenderContourSnapshot { - fn from(contour: &Contour) -> Self { - Self { - points: contour - .points() - .iter() - .map(RenderPointSnapshot::from) - .collect(), - closed: contour.is_closed(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct GlyphGeometry { - #[ts(rename = "xAdvance")] - pub x_advance: f64, - pub contours: Vec, - pub anchors: Vec, -} - -impl From<&GlyphSnapshot> for GlyphGeometry { - fn from(snap: &GlyphSnapshot) -> Self { - Self { - x_advance: snap.x_advance, - contours: snap.contours.clone(), - anchors: snap.anchors.clone(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct GlyphSnapshot { - pub unicode: u32, - pub name: String, - #[ts(rename = "xAdvance")] - pub x_advance: f64, - pub contours: Vec, - pub anchors: Vec, - #[serde(default)] - pub composite_contours: Vec, - #[ts(rename = "activeContourId", type = "ContourId | null")] - pub active_contour_id: Option, -} - -impl GlyphSnapshot { - pub fn from_edit_session(session: &EditSession) -> Self { - Self { - unicode: session.unicode(), - name: session.glyph_name().to_string(), - x_advance: session.width(), - contours: session.contours_iter().map(ContourSnapshot::from).collect(), - anchors: session - .layer() - .anchors_iter() - .map(AnchorSnapshot::from) - .collect(), - composite_contours: Vec::new(), - active_contour_id: session.active_contour_id().map(|id| id.raw().to_string()), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct CommandResult { - pub success: bool, - pub snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(rename = "affectedPointIds", type = "Array | null")] - pub affected_point_ids: Option>, - #[ts(rename = "canUndo")] - pub can_undo: bool, - #[ts(rename = "canRedo")] - pub can_redo: bool, -} - -impl CommandResult { - pub fn success(session: &EditSession, affected_point_ids: Vec) -> Self { - Self { - success: true, - snapshot: Some(GlyphSnapshot::from_edit_session(session)), - error: None, - affected_point_ids: Some( - affected_point_ids - .iter() - .map(|id| id.raw().to_string()) - .collect(), - ), - can_undo: false, - can_redo: false, - } - } - - pub fn success_simple(session: &EditSession) -> Self { - Self { - success: true, - snapshot: Some(GlyphSnapshot::from_edit_session(session)), - error: None, - affected_point_ids: None, - can_undo: false, - can_redo: false, - } - } - - pub fn error(message: impl Into) -> Self { - Self { - success: false, - snapshot: None, - error: Some(message.into()), - affected_point_ids: None, - can_undo: false, - can_redo: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] -pub struct MasterSnapshot { - pub source_id: String, - pub source_name: String, - pub is_default_source: bool, - pub location: Location, - pub geometry: GlyphGeometry, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Contour, GlyphLayer, PointType as IrPointType}; - - #[test] - fn point_snapshot_from_point() { - let mut contour = Contour::new(); - let point_id = contour.add_point(100.0, 200.0, IrPointType::OnCurve, true); - let point = contour.get_point(point_id).unwrap(); - - let snapshot = PointSnapshot::from(point); - - assert_eq!(snapshot.x, 100.0); - assert_eq!(snapshot.y, 200.0); - assert!(snapshot.smooth); - assert!(matches!(snapshot.point_type, PointType::OnCurve)); - } - - #[test] - fn contour_snapshot_from_contour() { - let mut contour = Contour::new(); - contour.add_point(0.0, 0.0, IrPointType::OnCurve, false); - contour.add_point(100.0, 0.0, IrPointType::OffCurve, false); - contour.add_point(100.0, 100.0, IrPointType::OnCurve, true); - contour.close(); - - let snapshot = ContourSnapshot::from(&contour); - - assert_eq!(snapshot.points.len(), 3); - assert!(snapshot.closed); - } - - #[test] - fn glyph_snapshot_from_edit_session() { - let mut session = EditSession::new("A".to_string(), 65, GlyphLayer::with_width(600.0)); - let contour_id = session.add_empty_contour(); - session - .add_point_to_contour(contour_id, 50.0, 50.0, IrPointType::OnCurve, false) - .unwrap(); - session - .layer_mut() - .add_anchor(crate::Anchor::new(Some("top".to_string()), 250.0, 700.0)); - - let snapshot = GlyphSnapshot::from_edit_session(&session); - - assert_eq!(snapshot.unicode, 65); - assert_eq!(snapshot.name, "A"); - assert_eq!(snapshot.x_advance, 600.0); - assert_eq!(snapshot.contours.len(), 1); - assert_eq!(snapshot.contours[0].points.len(), 1); - assert_eq!(snapshot.anchors.len(), 1); - assert_eq!(snapshot.anchors[0].name.as_deref(), Some("top")); - assert!(snapshot.composite_contours.is_empty()); - assert_eq!( - snapshot.active_contour_id, - Some(contour_id.raw().to_string()) - ); - } - - #[test] - fn command_result_success() { - let session = EditSession::new("B".to_string(), 66, GlyphLayer::with_width(500.0)); - - let result = CommandResult::success_simple(&session); - - assert!(result.success); - assert!(result.snapshot.is_some()); - assert!(result.error.is_none()); - } - - #[test] - fn command_result_error() { - let result = CommandResult::error("Something went wrong"); - - assert!(!result.success); - assert!(result.snapshot.is_none()); - assert_eq!(result.error, Some("Something went wrong".to_string())); - } - - #[test] - fn snapshot_serializes_to_json() { - let mut session = EditSession::new("C".to_string(), 67, GlyphLayer::with_width(550.0)); - let contour_id = session.add_empty_contour(); - session - .add_point_to_contour(contour_id, 10.0, 20.0, IrPointType::OnCurve, false) - .unwrap(); - - let snapshot = GlyphSnapshot::from_edit_session(&session); - let json = serde_json::to_string(&snapshot).unwrap(); - - assert!(json.contains("\"unicode\":67")); - assert!(json.contains("\"name\":\"C\"")); - assert!(json.contains("\"xAdvance\":550")); - assert!(json.contains("\"contours\":")); - assert!(json.contains("\"compositeContours\":")); - assert!(json.contains("\"activeContourId\":")); - } -} diff --git a/crates/shift-core/tests/font_loading.rs b/crates/shift-core/tests/font_loading.rs deleted file mode 100644 index 5864f4ab..00000000 --- a/crates/shift-core/tests/font_loading.rs +++ /dev/null @@ -1,668 +0,0 @@ -use shift_core::font_loader::FontLoader; -use shift_core::GlyphLayer; -use std::path::PathBuf; - -fn fixtures_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("fixtures") -} - -fn mutatorsans_ufo_path() -> PathBuf { - fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") -} - -fn get_main_layer(glyph: &shift_core::Glyph) -> Option<&GlyphLayer> { - glyph - .layers() - .values() - .max_by_key(|layer| layer.contours().len()) -} - -fn mutatorsans_ttf_path() -> PathBuf { - fixtures_path().join("fonts/mutatorsans/MutatorSans.ttf") -} - -fn mutatorsans_otf_path() -> PathBuf { - fixtures_path().join("fonts/mutatorsans/MutatorSans.otf") -} - -fn homenaje_glyphs_path() -> PathBuf { - fixtures_path().join("fonts/Homenaje.glyphs") -} - -#[test] -fn test_load_mutatorsans_ufo() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - panic!( - "Test font not found at {ufo_path:?}. Please download MutatorSans from fontmake repo." - ); - } - - let loader = FontLoader::new(); - let font = loader - .read_font(ufo_path.to_str().unwrap()) - .expect("Failed to load UFO font"); - - assert_eq!(font.glyph_count(), 48, "MutatorSans should have 48 glyphs"); -} - -#[test] -fn test_mutatorsans_ufo_metrics() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let metrics = font.metrics(); - assert_eq!(metrics.units_per_em, 1000.0, "UPM should be 1000"); - assert_eq!(metrics.ascender, 700.0, "Ascender should be 700"); - assert_eq!(metrics.descender, -200.0, "Descender should be -200"); - assert_eq!(metrics.cap_height, Some(700.0), "Cap height should be 700"); - assert_eq!(metrics.x_height, Some(500.0), "x-height should be 500"); -} - -#[test] -fn test_mutatorsans_ufo_metadata() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let metadata = font.metadata(); - assert_eq!( - metadata.family_name.as_deref(), - Some("MutatorMathTest"), - "Family name should be MutatorMathTest" - ); - assert_eq!( - metadata.style_name.as_deref(), - Some("LightCondensed"), - "Style name should be LightCondensed" - ); -} - -#[test] -fn test_mutatorsans_ufo_glyph_structure() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); - let layer = get_main_layer(glyph_a).expect("Glyph 'A' should have at least one layer"); - - assert!( - !layer.contours().is_empty(), - "Glyph 'A' should have contours" - ); - - for contour in layer.contours_iter() { - assert!(!contour.points().is_empty(), "Contours should have points"); - } -} - -#[test] -fn test_mutatorsans_ufo_point_types() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let glyph_o = font.glyph("O").expect("Glyph 'O' should exist"); - let layer = get_main_layer(glyph_o).expect("Glyph 'O' should have at least one layer"); - - let mut has_on_curve = false; - let mut has_off_curve = false; - - for contour in layer.contours_iter() { - for point in contour.points() { - match point.point_type() { - shift_core::PointType::OnCurve | shift_core::PointType::QCurve => { - has_on_curve = true - } - shift_core::PointType::OffCurve => has_off_curve = true, - } - } - } - - assert!(has_on_curve, "Glyph 'O' should have on-curve points"); - assert!( - has_off_curve, - "Glyph 'O' should have off-curve points (bezier curves)" - ); -} - -#[test] -fn test_load_mutatorsans_ttf() { - let ttf_path = mutatorsans_ttf_path(); - if !ttf_path.exists() { - println!("Skipping TTF test - file not found at {ttf_path:?}"); - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(ttf_path.to_str().unwrap()) - .expect("Failed to load TTF font"); - - assert!(font.glyph_count() > 0, "TTF should have glyphs"); -} - -#[test] -fn test_load_mutatorsans_otf() { - let otf_path = mutatorsans_otf_path(); - if !otf_path.exists() { - println!("Skipping OTF test - file not found at {otf_path:?}"); - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(otf_path.to_str().unwrap()) - .expect("Failed to load OTF font"); - - assert!(font.glyph_count() > 0, "OTF should have glyphs"); -} - -#[test] -fn test_load_homenaje_glyphs_file() { - let glyphs_path = homenaje_glyphs_path(); - if !glyphs_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(glyphs_path.to_str().unwrap()) - .expect("Failed to load Homenaje .glyphs font"); - - assert_eq!(font.metadata().family_name.as_deref(), Some("Homenaje")); - assert_eq!(font.metadata().version_major, Some(1)); - assert_eq!(font.metadata().version_minor, Some(100)); - assert_eq!(font.metrics().units_per_em, 1000.0); - assert_eq!(font.metrics().ascender, 700.0); - assert_eq!(font.metrics().descender, -160.0); - assert_eq!(font.metrics().cap_height, Some(700.0)); - assert_eq!(font.metrics().x_height, Some(520.0)); - assert!( - font.glyph_count() >= 300, - "Homenaje should have a substantial glyph set" - ); - - let fea = font - .features() - .fea_source() - .expect("Homenaje should have feature source"); - assert!(fea.contains("feature locl")); - assert!(fea.contains("feature frac")); - assert!(fea.contains("feature ordn")); - - assert_eq!( - font.kerning() - .get_kerning(&"A".to_string(), &"V".to_string()), - Some(-55.0) - ); - assert_eq!( - font.kerning() - .get_kerning(&"V".to_string(), &"a".to_string()), - Some(-65.0) - ); -} - -#[test] -fn test_homenaje_glyph_components_and_anchors() { - let glyphs_path = homenaje_glyphs_path(); - if !glyphs_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(glyphs_path.to_str().unwrap()).unwrap(); - - let aacute = font.glyph("Aacute").expect("Glyph 'Aacute' should exist"); - let aacute_layer = get_main_layer(aacute).expect("Aacute should have a layer"); - let component_bases: Vec<_> = aacute_layer - .components_iter() - .map(|c| c.base_glyph().to_string()) - .collect(); - assert_eq!(aacute_layer.components().len(), 2); - assert!(component_bases.contains(&"A".to_string())); - assert!(component_bases.contains(&"acute".to_string())); - - let u = font.glyph("u").expect("Glyph 'u' should exist"); - let u_layer = get_main_layer(u).expect("u should have a layer"); - let anchor_names: Vec<_> = u_layer - .anchors_iter() - .filter_map(|a| a.name().map(str::to_string)) - .collect(); - assert!(anchor_names.contains(&"top".to_string())); - assert!(anchor_names.contains(&"bottom".to_string())); - assert!(anchor_names.contains(&"ogonek".to_string())); -} - -#[test] -fn test_mutatorsans_ufo_anchors() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let glyph_e = font.glyph("E").expect("Glyph 'E' should exist"); - - let mut found_anchor = false; - for layer in glyph_e.layers().values() { - if !layer.anchors().is_empty() { - found_anchor = true; - let anchors: Vec<_> = layer.anchors_iter().collect(); - let top_anchor = anchors.iter().find(|a| a.name() == Some("top")); - assert!( - top_anchor.is_some(), - "If glyph 'E' has anchors, it should have 'top' anchor" - ); - break; - } - } - - assert!( - found_anchor, - "Glyph 'E' should have anchors in at least one layer" - ); -} - -#[test] -fn test_mutatorsans_ufo_components() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let glyph = font.glyph("Aacute").expect("Glyph 'Aacute' should exist"); - let layer = get_main_layer(glyph).expect("Glyph 'Aacute' should have at least one layer"); - - assert!( - !layer.components().is_empty(), - "Glyph 'Aacute' should have components" - ); - - let components: Vec<_> = layer.components_iter().collect(); - assert_eq!( - components.len(), - 2, - "Aacute should have 2 components (A + acute)" - ); - - let bases: Vec<_> = components.iter().map(|c| c.base_glyph().as_str()).collect(); - assert!( - bases.contains(&"A"), - "Aacute should reference 'A' component" - ); - assert!( - bases.contains(&"acute"), - "Aacute should reference 'acute' component" - ); -} - -#[test] -fn test_mutatorsans_ufo_kerning() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let kerning = font.kerning(); - - assert!(!kerning.is_empty(), "MutatorSans should have kerning data"); - assert!( - kerning.pairs().len() >= 3, - "MutatorSans should have at least 3 kerning pairs" - ); - - let t_a_kern = kerning.get_kerning(&"T".to_string(), &"A".to_string()); - assert_eq!(t_a_kern, Some(-75.0), "T -> A kerning should be -75"); - - let v_a_kern = kerning.get_kerning(&"V".to_string(), &"A".to_string()); - assert_eq!(v_a_kern, Some(-100.0), "V -> A kerning should be -100"); - - let a_v_kern = kerning.get_kerning(&"A".to_string(), &"V".to_string()); - assert_eq!(a_v_kern, Some(-15.0), "A -> V kerning should be -15"); -} - -#[test] -fn test_mutatorsans_ufo_multiple_layers() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let font = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - assert!( - font.layers().len() >= 2, - "MutatorSans should have multiple layers, got {}", - font.layers().len() - ); - - let layer_names: Vec<_> = font.layers().values().map(|l| l.name()).collect(); - assert!( - layer_names - .iter() - .any(|n| *n == "foreground" || *n == "public.default"), - "Should have foreground/default layer" - ); -} - -#[test] -fn test_ttf_has_glyph_contours() { - let ttf_path = mutatorsans_ttf_path(); - if !ttf_path.exists() { - println!("Skipping TTF contour test - file not found at {ttf_path:?}"); - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(ttf_path.to_str().unwrap()) - .expect("Failed to load TTF font"); - - let glyph_a = font - .glyph_by_unicode(65) - .expect("Glyph for 'A' (unicode 65) should exist"); - let layer = get_main_layer(glyph_a); - - assert!( - layer.is_some(), - "TTF glyph 'A' should have a layer with contours" - ); - - let layer = layer.unwrap(); - assert!( - !layer.contours().is_empty(), - "TTF glyph 'A' should have contours" - ); -} - -#[test] -fn test_ttf_point_types() { - let ttf_path = mutatorsans_ttf_path(); - if !ttf_path.exists() { - println!("Skipping TTF point type test - file not found"); - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(ttf_path.to_str().unwrap()) - .expect("Failed to load TTF font"); - - let glyph_o = font.glyph_by_unicode(79); - if glyph_o.is_none() { - println!("Skipping - glyph O not found in TTF"); - return; - } - - let glyph_o = glyph_o.unwrap(); - let layer = get_main_layer(glyph_o); - if layer.is_none() { - println!("Skipping - glyph O has no layer"); - return; - } - - let layer = layer.unwrap(); - let mut has_on_curve = false; - - for contour in layer.contours_iter() { - for point in contour.points() { - match point.point_type() { - shift_core::PointType::OnCurve | shift_core::PointType::QCurve => { - has_on_curve = true - } - shift_core::PointType::OffCurve => {} - } - } - } - - assert!( - has_on_curve, - "TTF glyph 'O' should have on-curve points (or QCurve)" - ); -} - -#[test] -fn test_otf_has_glyph_contours() { - let otf_path = mutatorsans_otf_path(); - if !otf_path.exists() { - println!("Skipping OTF contour test - file not found at {otf_path:?}"); - return; - } - - let loader = FontLoader::new(); - let font = loader - .read_font(otf_path.to_str().unwrap()) - .expect("Failed to load OTF font"); - - let glyph_a = font - .glyph_by_unicode(65) - .expect("Glyph for 'A' (unicode 65) should exist"); - let layer = get_main_layer(glyph_a); - - assert!( - layer.is_some(), - "OTF glyph 'A' should have a layer with contours" - ); - - let layer = layer.unwrap(); - assert!( - !layer.contours().is_empty(), - "OTF glyph 'A' should have contours" - ); -} - -// --- Variable font (multi-master .glyphs) tests --- - -fn mutatorsans_variable_glyphs_path() -> PathBuf { - fixtures_path().join("fonts/MutatorSansVariable.glyphs") -} - -#[test] -fn test_variable_glyphs_is_variable() { - let path = mutatorsans_variable_glyphs_path(); - let loader = FontLoader::new(); - let font = loader - .read_font(path.to_str().unwrap()) - .expect("Failed to load variable .glyphs font"); - - assert!(font.is_variable(), "Multi-master font should be variable"); -} - -#[test] -fn test_variable_glyphs_axes() { - let path = mutatorsans_variable_glyphs_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let axes = font.axes(); - assert_eq!(axes.len(), 1, "Should have 1 axis"); - assert_eq!(axes[0].tag(), "wght"); - assert_eq!(axes[0].name(), "Weight"); - assert_eq!(axes[0].minimum(), 100.0); - assert_eq!(axes[0].maximum(), 900.0); - assert_eq!(axes[0].default(), 100.0); -} - -#[test] -fn test_variable_glyphs_sources() { - let path = mutatorsans_variable_glyphs_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let sources = font.sources(); - assert_eq!(sources.len(), 2, "Should have 2 sources (Light and Bold)"); - - let light = &sources[0]; - assert_eq!(light.location().get("wght"), Some(100.0)); - - let bold = &sources[1]; - assert_eq!(bold.location().get("wght"), Some(900.0)); -} - -#[test] -fn test_variable_glyphs_glyph_has_multiple_layers() { - let path = mutatorsans_variable_glyphs_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); - assert_eq!( - glyph_a.layers().len(), - 2, - "Variable glyph 'A' should have 2 layers (one per master)" - ); -} - -#[test] -fn test_variable_glyphs_masters_are_compatible() { - let path = mutatorsans_variable_glyphs_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); - let layers: Vec<_> = glyph_a.layers().values().collect(); - assert_eq!(layers.len(), 2); - - // Both layers should have the same number of contours - assert_eq!( - layers[0].contours().len(), - layers[1].contours().len(), - "Masters should have the same number of contours" - ); - - // Both outer contours should have the same number of points - let contour0_points: usize = layers[0] - .contours() - .values() - .map(|c| c.points().len()) - .sum(); - let contour1_points: usize = layers[1] - .contours() - .values() - .map(|c| c.points().len()) - .sum(); - assert_eq!( - contour0_points, contour1_points, - "Masters should have the same total point count" - ); -} - -// --- Designspace (.designspace) tests --- - -fn mutatorsans_designspace_path() -> PathBuf { - fixtures_path().join("fonts/mutatorsans-variable/MutatorSans.designspace") -} - -#[test] -fn test_designspace_loads() { - let path = mutatorsans_designspace_path(); - let loader = FontLoader::new(); - let font = loader - .read_font(path.to_str().unwrap()) - .expect("Failed to load designspace"); - - assert!(font.is_variable(), "Designspace font should be variable"); - assert!( - font.glyph_count() > 10, - "MutatorSans should have many glyphs" - ); -} - -#[test] -fn test_designspace_axes() { - let path = mutatorsans_designspace_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let axes = font.axes(); - assert_eq!(axes.len(), 2, "MutatorSans has width + weight axes"); - assert_eq!(axes[0].tag(), "wdth"); - assert_eq!(axes[0].name(), "width"); - assert_eq!(axes[0].minimum(), 0.0); - assert_eq!(axes[0].maximum(), 1000.0); - assert_eq!(axes[1].tag(), "wght"); - assert_eq!(axes[1].name(), "weight"); -} - -#[test] -fn test_designspace_sources() { - let path = mutatorsans_designspace_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let sources = font.sources(); - // 4 main masters + 3 support layer sources = 7 - assert_eq!( - sources.len(), - 7, - "Should have 7 sources (4 masters + 3 support)" - ); - - // Default source (LightCondensed) at (0, 0) - let default = &sources[0]; - assert_eq!(default.location().get("wdth"), Some(0.0)); - assert_eq!(default.location().get("wght"), Some(0.0)); - assert!(default.filename().is_some()); -} - -#[test] -fn test_designspace_glyph_has_multiple_layers() { - let path = mutatorsans_designspace_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); - // At least 4 layers from the 4 main masters - assert!( - glyph_a.layers().len() >= 4, - "Glyph A should have at least 4 layers, got {}", - glyph_a.layers().len() - ); -} - -#[test] -fn test_designspace_metadata_from_default_source() { - let path = mutatorsans_designspace_path(); - let loader = FontLoader::new(); - let font = loader.read_font(path.to_str().unwrap()).unwrap(); - - let metadata = font.metadata(); - assert_eq!( - metadata.family_name.as_deref(), - Some("MutatorMathTest"), - "Family name should come from designspace source" - ); -} diff --git a/crates/shift-core/tests/interpolation_parity.rs b/crates/shift-core/tests/interpolation_parity.rs deleted file mode 100644 index 1234f0c1..00000000 --- a/crates/shift-core/tests/interpolation_parity.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Parity-test fixture writer. -//! -//! Loads the real MutatorSans designspace, builds variation data for a glyph, -//! computes the expected interpolated values via fontdrasil at a known target, -//! and writes a JSON fixture for the TS parity test (in -//! `apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts`) to -//! read back and assert the TS port agrees to within f64 precision. -//! -//! Run: `cargo test -p shift-core --test interpolation_parity`. - -use std::collections::{BTreeMap, HashMap}; -use std::fs; -use std::path::PathBuf; -use std::str::FromStr; - -use fontdrasil::coords::NormalizedLocation; -use fontdrasil::types::Tag; -use fontdrasil::variations::VariationModel; -use serde::Serialize; - -use shift_core::font_loader::FontLoader; -use shift_core::interpolation::{ - build_master_snapshots, get_glyph_variation_data, GlyphVariationData, -}; -use shift_core::snapshot::GlyphGeometry; -use shift_ir::variation::to_fd_location; -use shift_ir::Location; - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .to_path_buf() -} - -fn fixture_designspace() -> PathBuf { - workspace_root().join("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace") -} - -fn fixture_output() -> PathBuf { - workspace_root().join("packages/types/__fixtures__/variation_parity.json") -} - -/// Local copy of the `flatten` walk used by `get_glyph_variation_data`. -/// Kept private to the test so production `flatten` can stay private. -/// Order MUST match shift-core::interpolation::flatten exactly. -fn flatten_geometry(g: &GlyphGeometry) -> Vec { - let mut v = vec![g.x_advance]; - for c in &g.contours { - for p in &c.points { - v.push(p.x); - v.push(p.y); - } - } - for a in &g.anchors { - v.push(a.x); - v.push(a.y); - } - v -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct MasterEntry { - source_name: String, - is_default_source: bool, - designspace_location: BTreeMap, - normalised_location: BTreeMap, - /// Flat values at this master, in `flatten()` order. - /// `interpolate(data, normalisedLocation)` must equal this within 1e-9. - expected: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct Fixture { - /// For diagnostics — which glyph we sampled. - glyph_name: String, - - /// Mid-designspace target (for the headline parity assertion). - designspace_target: BTreeMap, - normalised_location: BTreeMap, - - /// What the TS `interpolate()` consumes — same shape Rust ships over NAPI. - data: GlyphVariationData, - - /// Ground truth from fontdrasil's `interpolate_from_deltas` at the mid target. - /// TS port must match within ~1e-9. - expected: Vec, - - /// Per-master round-trip data: at each master's location, interpolation must - /// recover that master's exact flat values. Catches unpacking drift between - /// Rust's flatten() and TS's applyValues walk. - masters: Vec, -} - -#[test] -fn write_parity_fixture() { - let designspace = fixture_designspace(); - let loader = FontLoader::new(); - let font = loader - .read_font(designspace.to_str().unwrap()) - .expect("load MutatorSans designspace"); - - // "A" is present in all four corner masters of MutatorSans — safe choice. - const GLYPH: &str = "A"; - let glyph = font.glyph(GLYPH).expect("glyph A missing"); - - let masters = build_master_snapshots(&font, glyph).expect("not variable / no masters"); - assert!( - masters.len() >= 4, - "expected ≥ 4 masters for A, got {}", - masters.len() - ); - - let axes = font.axes(); - let data = get_glyph_variation_data(&masters, axes).expect("variation data"); - - // Pick a non-trivial target — middle of designspace on each axis. - let mut target = Location::new(); - for axis in axes { - let mid = (axis.minimum() + axis.maximum()) / 2.0; - target.set(axis.tag().to_string(), mid); - } - let target_norm = to_fd_location(&target, axes); - - // Compute expected via fontdrasil directly — this is the ground truth. - let ordered_axes: Vec = axes - .iter() - .filter_map(|a| Tag::from_str(a.tag()).ok()) - .collect(); - - let mut points: HashMap> = HashMap::new(); // fontdrasil API takes HashMap - for m in &masters { - let loc = to_fd_location(&m.location, axes); - points.insert(loc, flatten_geometry(&m.geometry)); - } - let model = VariationModel::new(points.keys().cloned().collect(), ordered_axes); - let model_deltas = model.deltas::(&points).expect("compute deltas"); - let expected: Vec = model.interpolate_from_deltas(&target_norm, &model_deltas); - - let designspace_target: BTreeMap = target - .iter() - .map(|(tag, value)| (tag.clone(), *value)) - .collect(); - let normalised_location: BTreeMap = target_norm - .iter() - .map(|(tag, coord)| (tag.to_string(), coord.into_inner().into_inner())) - .collect(); - - // Per-master round-trip data — at each master's location, interpolation - // must equal that master's flat values. - let master_entries: Vec = masters - .iter() - .map(|m| { - let m_norm = to_fd_location(&m.location, axes); - let m_expected = model.interpolate_from_deltas(&m_norm, &model_deltas); - MasterEntry { - source_name: m.source_name.clone(), - is_default_source: m.is_default_source, - designspace_location: m - .location - .iter() - .map(|(k, v)| (k.clone(), *v)) - .collect::>(), - normalised_location: m_norm - .iter() - .map(|(t, c)| (t.to_string(), c.into_inner().into_inner())) - .collect::>(), - expected: m_expected, - } - }) - .collect(); - - let fixture = Fixture { - glyph_name: GLYPH.to_string(), - designspace_target, - normalised_location, - data, - expected, - masters: master_entries, - }; - - let out = fixture_output(); - // Trailing newline to match what pre-commit's end-of-file-fixer expects; - // without it the fixer would re-add it, breaking idempotency on re-runs. - let new_json = format!("{}\n", serde_json::to_string_pretty(&fixture).unwrap()); - - // Idempotent: only write when content actually changes. This keeps the - // fixture stable across CI runs and pre-commit hooks (which run cargo - // test) — without it, every run would rewrite the file and the hook - // would flag "files modified after staging." - let needs_write = match fs::read_to_string(&out) { - Ok(existing) => existing != new_json, - Err(_) => true, - }; - if needs_write { - fs::create_dir_all(out.parent().unwrap()).expect("mkdir __fixtures__"); - fs::write(&out, &new_json).expect("write fixture"); - println!("wrote parity fixture → {}", out.display()); - } else { - println!("parity fixture up to date ({})", out.display()); - } -} diff --git a/crates/shift-core/tests/round_trip.rs b/crates/shift-core/tests/round_trip.rs deleted file mode 100644 index 98fdaa5d..00000000 --- a/crates/shift-core/tests/round_trip.rs +++ /dev/null @@ -1,922 +0,0 @@ -use shift_core::font_loader::FontLoader; -use shift_core::{Anchor, GlyphLayer}; -use std::path::PathBuf; - -fn fixtures_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("fixtures") -} - -fn mutatorsans_ufo_path() -> PathBuf { - fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") -} - -fn get_main_layer(glyph: &shift_core::Glyph) -> Option<&GlyphLayer> { - glyph - .layers() - .values() - .max_by_key(|layer| layer.contours().len()) -} - -#[test] -fn test_ufo_round_trip_glyph_count() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - let original_count = original.glyph_count(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - assert_eq!( - reloaded.glyph_count(), - original_count, - "Glyph count should match after round-trip" - ); -} - -#[test] -fn test_ufo_round_trip_metrics() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let orig_metrics = original.metrics(); - let reload_metrics = reloaded.metrics(); - - assert_eq!( - orig_metrics.units_per_em, reload_metrics.units_per_em, - "UPM should match after round-trip" - ); - assert_eq!( - orig_metrics.ascender, reload_metrics.ascender, - "Ascender should match after round-trip" - ); - assert_eq!( - orig_metrics.descender, reload_metrics.descender, - "Descender should match after round-trip" - ); - assert_eq!( - orig_metrics.cap_height, reload_metrics.cap_height, - "Cap height should match after round-trip" - ); - assert_eq!( - orig_metrics.x_height, reload_metrics.x_height, - "x-height should match after round-trip" - ); -} - -#[test] -fn test_ufo_round_trip_point_coordinates() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "A"; - let orig_glyph = original - .glyph(glyph_name) - .expect("Original glyph A missing"); - let reload_glyph = reloaded - .glyph(glyph_name) - .expect("Reloaded glyph A missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - assert_eq!( - orig_layer.contours().len(), - reload_layer.contours().len(), - "Contour count should match for glyph '{glyph_name}'" - ); - - let mut orig_contours: Vec<_> = orig_layer.contours_iter().collect(); - let mut reload_contours: Vec<_> = reload_layer.contours_iter().collect(); - - orig_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - reload_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - - for (orig_contour, reload_contour) in orig_contours.iter().zip(reload_contours.iter()) { - assert_eq!( - orig_contour.points().len(), - reload_contour.points().len(), - "Point count should match in contour" - ); - - for (orig_point, reload_point) in orig_contour - .points() - .iter() - .zip(reload_contour.points().iter()) - { - assert!( - (orig_point.x() - reload_point.x()).abs() < 0.001, - "X coordinate should match: {} vs {}", - orig_point.x(), - reload_point.x() - ); - assert!( - (orig_point.y() - reload_point.y()).abs() < 0.001, - "Y coordinate should match: {} vs {}", - orig_point.y(), - reload_point.y() - ); - } - } -} - -#[test] -fn test_ufo_round_trip_point_types() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "O"; - let orig_glyph = original - .glyph(glyph_name) - .expect("Original glyph O missing"); - let reload_glyph = reloaded - .glyph(glyph_name) - .expect("Reloaded glyph O missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - let mut orig_contours: Vec<_> = orig_layer.contours_iter().collect(); - let mut reload_contours: Vec<_> = reload_layer.contours_iter().collect(); - - orig_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - reload_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - - for (orig_contour, reload_contour) in orig_contours.iter().zip(reload_contours.iter()) { - for (orig_point, reload_point) in orig_contour - .points() - .iter() - .zip(reload_contour.points().iter()) - { - assert_eq!( - orig_point.point_type(), - reload_point.point_type(), - "Point types should match" - ); - } - } -} - -#[test] -fn test_ufo_round_trip_smooth_flags() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "O"; - let orig_glyph = original - .glyph(glyph_name) - .expect("Original glyph O missing"); - let reload_glyph = reloaded - .glyph(glyph_name) - .expect("Reloaded glyph O missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - let mut orig_contours: Vec<_> = orig_layer.contours_iter().collect(); - let mut reload_contours: Vec<_> = reload_layer.contours_iter().collect(); - - orig_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - reload_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - - for (orig_contour, reload_contour) in orig_contours.iter().zip(reload_contours.iter()) { - for (orig_point, reload_point) in orig_contour - .points() - .iter() - .zip(reload_contour.points().iter()) - { - assert_eq!( - orig_point.is_smooth(), - reload_point.is_smooth(), - "Smooth flags should match" - ); - } - } -} - -#[test] -fn test_ufo_round_trip_contour_closed_state() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "A"; - let orig_glyph = original - .glyph(glyph_name) - .expect("Original glyph A missing"); - let reload_glyph = reloaded - .glyph(glyph_name) - .expect("Reloaded glyph A missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - let mut orig_contours: Vec<_> = orig_layer.contours_iter().collect(); - let mut reload_contours: Vec<_> = reload_layer.contours_iter().collect(); - - orig_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - reload_contours.sort_by(|a, b| { - let a_first = a.points().first().map(|p| (p.x() as i64, p.y() as i64)); - let b_first = b.points().first().map(|p| (p.x() as i64, p.y() as i64)); - a_first.cmp(&b_first) - }); - - for (orig_contour, reload_contour) in orig_contours.iter().zip(reload_contours.iter()) { - assert_eq!( - orig_contour.is_closed(), - reload_contour.is_closed(), - "Contour closed state should match" - ); - } -} - -#[test] -fn test_ufo_round_trip_glyph_widths() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - for glyph_name in ["A", "B", "O", "space"] { - let orig_glyph = match original.glyph(glyph_name) { - Some(g) => g, - None => continue, - }; - let reload_glyph = reloaded.glyph(glyph_name).expect("Reloaded glyph missing"); - - let mut orig_widths: Vec<_> = orig_glyph.layers().values().map(|l| l.width()).collect(); - let mut reload_widths: Vec<_> = reload_glyph.layers().values().map(|l| l.width()).collect(); - - orig_widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); - reload_widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); - - assert_eq!( - orig_widths.len(), - reload_widths.len(), - "Layer count should match for glyph '{glyph_name}'" - ); - - for (orig_w, reload_w) in orig_widths.iter().zip(reload_widths.iter()) { - assert!( - (orig_w - reload_w).abs() < 0.001, - "Width should match for glyph '{glyph_name}': {orig_w} vs {reload_w}" - ); - } - } -} - -#[test] -fn test_ufo_round_trip_kerning_pairs() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let orig_kerning = original.kerning(); - let reload_kerning = reloaded.kerning(); - - assert!( - !orig_kerning.is_empty(), - "MutatorSans should have kerning data" - ); - assert_eq!( - orig_kerning.pairs().len(), - reload_kerning.pairs().len(), - "Kerning pair count should match after round-trip" - ); - - for orig_pair in orig_kerning.pairs() { - let found = reload_kerning.pairs().iter().any(|reload_pair| { - orig_pair.first == reload_pair.first - && orig_pair.second == reload_pair.second - && (orig_pair.value - reload_pair.value).abs() < 0.001 - }); - assert!( - found, - "Kerning pair {orig_pair:?} not found after round-trip" - ); - } -} - -#[test] -fn test_ufo_round_trip_kerning_groups() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let orig_kerning = original.kerning(); - let reload_kerning = reloaded.kerning(); - - assert_eq!( - orig_kerning.groups1().len(), - reload_kerning.groups1().len(), - "Kerning group1 count should match" - ); - assert_eq!( - orig_kerning.groups2().len(), - reload_kerning.groups2().len(), - "Kerning group2 count should match" - ); - - for (name, members) in orig_kerning.groups1() { - let reload_members = reload_kerning - .groups1() - .get(name) - .unwrap_or_else(|| panic!("Group1 '{name}' should exist after round-trip")); - assert_eq!( - members.len(), - reload_members.len(), - "Group1 '{name}' member count should match" - ); - for member in members { - assert!( - reload_members.contains(member), - "Group1 '{name}' should contain member '{member}'" - ); - } - } -} - -#[test] -fn test_ufo_round_trip_kerning_lookup() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let test_pairs = [("T", "A", -75.0), ("V", "A", -100.0), ("A", "V", -15.0)]; - - for (first, second, expected) in test_pairs { - let orig_value = original - .kerning() - .get_kerning(&first.to_string(), &second.to_string()); - let reload_value = reloaded - .kerning() - .get_kerning(&first.to_string(), &second.to_string()); - - assert_eq!( - orig_value, - Some(expected), - "Original kerning for {first} -> {second} should be {expected}" - ); - assert_eq!( - reload_value, - Some(expected), - "Reloaded kerning for {first} -> {second} should be {expected}" - ); - } -} - -#[test] -fn test_ufo_round_trip_components() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "Aacute"; - let orig_glyph = original - .glyph(glyph_name) - .expect("Original glyph Aacute missing"); - let reload_glyph = reloaded - .glyph(glyph_name) - .expect("Reloaded glyph Aacute missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - assert!( - !orig_layer.components().is_empty(), - "Aacute should have components" - ); - - let orig_components: Vec<_> = orig_layer.components_iter().collect(); - let reload_components: Vec<_> = reload_layer.components_iter().collect(); - - assert_eq!( - orig_components.len(), - reload_components.len(), - "Component count should match for Aacute" - ); - - assert_eq!(orig_components.len(), 2, "Aacute should have 2 components"); - - for orig_comp in &orig_components { - let found = reload_components - .iter() - .any(|reload_comp| orig_comp.base_glyph() == reload_comp.base_glyph()); - assert!( - found, - "Component with base '{}' not found after round-trip", - orig_comp.base_glyph() - ); - } - - let orig_bases: Vec<_> = orig_components.iter().map(|c| c.base_glyph()).collect(); - assert!( - orig_bases.contains(&&"A".to_string()), - "Aacute should have A component" - ); - assert!( - orig_bases.contains(&&"acute".to_string()), - "Aacute should have acute component" - ); -} - -#[test] -fn test_ufo_round_trip_component_transforms() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "Aacute"; - let orig_glyph = original.glyph(glyph_name).expect("Original glyph missing"); - let reload_glyph = reloaded.glyph(glyph_name).expect("Reloaded glyph missing"); - - let orig_layer = get_main_layer(orig_glyph).expect("Original glyph should have a layer"); - let reload_layer = get_main_layer(reload_glyph).expect("Reloaded glyph should have a layer"); - - let orig_acute = orig_layer - .components_iter() - .find(|c| c.base_glyph() == "acute") - .expect("Original should have acute component"); - let reload_acute = reload_layer - .components_iter() - .find(|c| c.base_glyph() == "acute") - .expect("Reloaded should have acute component"); - - let orig_matrix = orig_acute.matrix(); - let reload_matrix = reload_acute.matrix(); - - assert!( - (orig_matrix.dx - 99.0).abs() < 0.1, - "Original acute xOffset should be ~99, got {}", - orig_matrix.dx - ); - assert!( - (orig_matrix.dy - 20.0).abs() < 0.1, - "Original acute yOffset should be ~20, got {}", - orig_matrix.dy - ); - - assert!( - (orig_matrix.dx - reload_matrix.dx).abs() < 0.1, - "xOffset should match after round-trip: {} vs {}", - orig_matrix.dx, - reload_matrix.dx - ); - assert!( - (orig_matrix.dy - reload_matrix.dy).abs() < 0.1, - "yOffset should match after round-trip: {} vs {}", - orig_matrix.dy, - reload_matrix.dy - ); -} - -#[test] -fn test_ufo_round_trip_multiple_layers() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let orig_layer_count = original.layers().len(); - let reload_layer_count = reloaded.layers().len(); - - assert!( - orig_layer_count >= 2, - "MutatorSans should have multiple layers, got {orig_layer_count}" - ); - - assert_eq!( - orig_layer_count, reload_layer_count, - "Layer count should match after round-trip: {orig_layer_count} vs {reload_layer_count}" - ); - - for orig_layer in original.layers().values() { - let orig_name = orig_layer.name(); - let found = reloaded.layers().values().any(|l| l.name() == orig_name); - assert!(found, "Layer '{orig_name}' should exist after round-trip"); - } -} - -#[test] -fn test_ufo_round_trip_layer_glyph_counts() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - for (orig_layer_id, orig_layer) in original.layers() { - let orig_name = orig_layer.name(); - - let mut orig_glyph_count = 0; - for glyph in original.glyphs().values() { - if glyph.layer(*orig_layer_id).is_some() { - orig_glyph_count += 1; - } - } - - let (&reload_layer_id, _) = reloaded - .layers() - .iter() - .find(|(_, l)| l.name() == orig_name) - .unwrap_or_else(|| panic!("Layer '{orig_name}' should exist")); - - let mut reload_glyph_count = 0; - for glyph in reloaded.glyphs().values() { - if glyph.layer(reload_layer_id).is_some() { - reload_glyph_count += 1; - } - } - - assert_eq!( - orig_glyph_count, reload_glyph_count, - "Glyph count in layer '{orig_name}' should match: {orig_glyph_count} vs {reload_glyph_count}" - ); - } -} - -#[test] -fn test_ufo_round_trip_anchors_preserve_order_and_values() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph_name = "E"; - let orig_glyph = original.glyph(glyph_name).expect("Original glyph missing"); - let reload_glyph = reloaded.glyph(glyph_name).expect("Reloaded glyph missing"); - - for (orig_layer_id, orig_font_layer) in original.layers() { - let Some(orig_layer) = orig_glyph.layer(*orig_layer_id) else { - continue; - }; - - let reload_layer_id = reloaded - .layers() - .iter() - .find(|(_, layer)| layer.name() == orig_font_layer.name()) - .map(|(id, _)| *id) - .unwrap_or_else(|| panic!("Missing reloaded layer '{}'", orig_font_layer.name())); - - let Some(reload_layer) = reload_glyph.layer(reload_layer_id) else { - panic!( - "Glyph '{}' missing in reloaded layer '{}'", - glyph_name, - orig_font_layer.name() - ); - }; - - let orig_anchors: Vec<_> = orig_layer.anchors_iter().collect(); - let reload_anchors: Vec<_> = reload_layer.anchors_iter().collect(); - - assert_eq!( - orig_anchors.len(), - reload_anchors.len(), - "Anchor count should match after round-trip in layer '{}'", - orig_font_layer.name() - ); - - for (orig_anchor, reload_anchor) in orig_anchors.iter().zip(reload_anchors.iter()) { - assert_eq!( - orig_anchor.name(), - reload_anchor.name(), - "Anchor names should preserve order and value in layer '{}'", - orig_font_layer.name() - ); - assert!( - (orig_anchor.x() - reload_anchor.x()).abs() < 0.5, - "Anchor x should match after round-trip in layer '{}': {} vs {}", - orig_font_layer.name(), - orig_anchor.x(), - reload_anchor.x() - ); - assert!( - (orig_anchor.y() - reload_anchor.y()).abs() < 0.5, - "Anchor y should match after round-trip in layer '{}': {} vs {}", - orig_font_layer.name(), - orig_anchor.y(), - reload_anchor.y() - ); - } - } -} - -#[test] -fn test_ufo_round_trip_preserves_unnamed_anchor() { - let ufo_path = mutatorsans_ufo_path(); - if !ufo_path.exists() { - return; - } - - let loader = FontLoader::new(); - let mut original = loader.read_font(ufo_path.to_str().unwrap()).unwrap(); - - let target_layer_id = { - let glyph = original - .glyph_mut("E") - .expect("Glyph 'E' should exist to append unnamed anchor"); - let target_layer_id = glyph - .layers() - .iter() - .max_by_key(|(_, layer)| layer.contours().len()) - .map(|(id, _)| *id) - .expect("Glyph 'E' should have at least one layer"); - let layer = glyph - .layer_mut(target_layer_id) - .expect("Layer should exist"); - layer.add_anchor(Anchor::new(None::, 123.0, 456.0)); - target_layer_id - }; - let target_layer_name = original - .layers() - .get(&target_layer_id) - .map(|layer| layer.name().to_string()) - .expect("Target layer should exist"); - - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let temp_ufo = temp_dir.path().join("test_output.ufo"); - - loader - .write_font(&original, temp_ufo.to_str().unwrap()) - .expect("Failed to write UFO"); - - let reloaded = loader - .read_font(temp_ufo.to_str().unwrap()) - .expect("Failed to reload UFO"); - - let glyph = reloaded - .glyph("E") - .expect("Reloaded glyph 'E' should exist after round-trip"); - let reload_layer_id = reloaded - .layers() - .iter() - .find(|(_, layer)| layer.name() == target_layer_name) - .map(|(id, _)| *id) - .unwrap_or_else(|| reloaded.default_layer_id()); - let layer = glyph - .layer(reload_layer_id) - .or_else(|| get_main_layer(glyph)) - .expect("Reloaded glyph should have a matching layer"); - let unnamed = layer.anchors_iter().find(|a| { - a.name().is_none() && (a.x() - 123.0).abs() < 0.001 && (a.y() - 456.0).abs() < 0.001 - }); - - assert!( - unnamed.is_some(), - "Unnamed anchor should survive UFO round-trip with preserved coordinates" - ); -} diff --git a/crates/shift-core/Cargo.toml b/crates/shift-edit/Cargo.toml similarity index 54% rename from crates/shift-core/Cargo.toml rename to crates/shift-edit/Cargo.toml index 28cb1cab..e4c470b3 100644 --- a/crates/shift-core/Cargo.toml +++ b/crates/shift-edit/Cargo.toml @@ -1,12 +1,18 @@ [package] edition = "2021" -name = "shift-core" +name = "shift-edit" version = "0.0.0" +authors = ["Kostya Farber "] +license = "MIT" [lib] crate-type = ["rlib"] [dependencies] +shift-ir = { workspace = true } +shift-backends = { workspace = true } +shift-wire = { workspace = true } + bitflags = "2.9.1" fontc = "0.2.0" fontdrasil = "0.4.0" @@ -14,9 +20,4 @@ norad = "0.16.0" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0" skrifa = "0.32.0" -ts-rs = "11.0.1" -shift-ir = { path = "../shift-ir" } -shift-backends = { path = "../shift-backends" } - -[dev-dependencies] -tempfile = "3" +thiserror = "2.0.18" diff --git a/crates/shift-edit/docs/DOCS.md b/crates/shift-edit/docs/DOCS.md new file mode 100644 index 00000000..e2f896df --- /dev/null +++ b/crates/shift-edit/docs/DOCS.md @@ -0,0 +1,82 @@ +# shift-edit + +Editing logic and composite helpers for the Shift font editor. + +## Architecture Invariants + +**Architecture Invariant:** `EditSession` operates on a `GlyphLayer`, not a full `Glyph`. A session holds one editable layer plus glyph metadata. WHY: layers are the unit of editing in font design. + +**Architecture Invariant:** Core data types (`Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, entity IDs) live in `shift-ir`, not here. `shift-edit` re-exports them from `shift_ir` for convenience. + +**Architecture Invariant:** State restore uses `GlyphStructure + values`, not old bridge snapshots. The structure owns stable entity ordering; values are the flat numeric payload in that order. + +**Architecture Invariant:** `shift-edit` must not export TypeScript types. Bridge DTOs live in `shift-wire`; TypeScript declarations are generated from `shift-bridge/index.d.ts` into `@shift/types/bridge`. + +**Architecture Invariant:** Composite resolution is read-only. `flatten_component_contours_for_layer` and `resolve_component_instances_for_layer` produce derived geometry and never mutate source glyphs. + +## Codemap + +``` +src/ + lib.rs -- public API and shift-ir re-exports + edit_session.rs -- mutable glyph-layer editing context + state.rs -- GlyphStructure/values restore helpers and flat value extraction + composite.rs -- composite glyph resolution and derived contours + dependency_graph.rs -- component dependency index + curve.rs -- tight curve bounds helpers + vec2.rs -- 2D vector math +``` + +## Key Types + +- `EditSession` -- mutable editing context wrapping a `GlyphLayer` with glyph metadata. +- `BulkNodePositionUpdates` -- typed-array-friendly absolute position update payload for hot path sync. +- `EditableNode` -- point/anchor reference enum for editable node operations. +- `GlyphStructure` / values -- state restore and bridge-facing edit result shape, owned canonically by `shift-wire`. +- `DependencyGraph` -- bidirectional component dependency index. +- `ResolvedContour` -- derived contour from composite flattening. + +## Editing Flow + +1. Caller creates an `EditSession` from a glyph name, unicode, and `GlyphLayer`. +2. The session mutates contours, points, anchors, width, and bulk node positions. +3. Bridge methods convert the session layer into `shift-wire` structure/value change DTOs. +4. Undo/redo restore rebuilds layer content from `GlyphStructure + values`. +5. The session is consumed via `into_layer()` when the bridge commits the active edit back to the font. + +## Workflow Recipes + +### Add a new editing operation to EditSession + +1. Add the method to `EditSession` in `edit_session.rs`. +2. If it returns results to JS, create or reuse a DTO in `shift-wire`. +3. Wire the method through `shift-bridge` NAPI bindings. +4. Rebuild `shift-bridge` declarations. +5. Run `pnpm generate:bridge-types`. +6. Run `cargo test -p shift-edit` and `cargo test -p shift-bridge`. + +### Add a new bridge field + +1. Add the canonical DTO field in `shift-wire`. +2. Add the NAPI adapter field in `shift-wire/src/bridges/napi`. +3. Rebuild `shift-bridge` declarations. +4. Run `pnpm generate:bridge-types` to update `@shift/types/bridge`. + +## Gotchas + +- `apply_boolean_op` removes both input contours even if the boolean operation produces zero output contours. +- Point lookup across contours is linear. For hot paths with many points, prefer bulk position APIs that iterate contours once. +- Composite-derived points are render-time artifacts, not editable identities. + +## Verification + +```bash +cargo test -p shift-edit +cargo clippy -p shift-edit +``` + +## Related + +- `shift-ir` -- canonical Rust data model. +- `shift-wire` -- bridge DTOs and NAPI adapter wrappers. +- `shift-bridge` -- NAPI bridge exposing edit/session/persistence operations. diff --git a/crates/shift-core/src/composite.rs b/crates/shift-edit/src/composite.rs similarity index 97% rename from crates/shift-core/src/composite.rs rename to crates/shift-edit/src/composite.rs index 5cab7fc2..6dadee84 100644 --- a/crates/shift-core/src/composite.rs +++ b/crates/shift-edit/src/composite.rs @@ -16,7 +16,6 @@ //! non-cyclic branches still contribute geometry. use crate::curve::segment_bounds; -use crate::snapshot::{RenderContourSnapshot, RenderPointSnapshot}; use crate::{ Contour, CurveSegment, CurveSegmentIter, Font, Glyph, GlyphLayer, Point, PointId, Transform, }; @@ -98,6 +97,7 @@ pub fn preferred_layer_for_glyph(glyph: &Glyph) -> Option<&GlyphLayer> { .layers() .values() .max_by_key(|layer| layer_complexity(layer)) + .map(|layer| layer.as_ref()) } fn transform_contour_points(contour: &Contour, transform: Transform) -> ResolvedContour { @@ -325,26 +325,6 @@ pub fn resolve_component_instances_for_layer( out } -/// Converts resolved contours into lightweight render snapshots. -pub fn resolved_to_render_contours(resolved: &[ResolvedContour]) -> Vec { - resolved - .iter() - .map(|contour| RenderContourSnapshot { - points: contour - .points - .iter() - .map(|point| RenderPointSnapshot { - x: point.x(), - y: point.y(), - point_type: point.point_type().into(), - smooth: point.is_smooth(), - }) - .collect(), - closed: contour.closed, - }) - .collect() -} - /// Builds a single SVG path string from root contours and resolved component /// contours. pub fn layer_to_svg_path(layer: &GlyphLayer, component_contours: &[ResolvedContour]) -> String { diff --git a/crates/shift-core/src/curve.rs b/crates/shift-edit/src/curve.rs similarity index 100% rename from crates/shift-core/src/curve.rs rename to crates/shift-edit/src/curve.rs diff --git a/crates/shift-core/src/dependency_graph.rs b/crates/shift-edit/src/dependency_graph.rs similarity index 100% rename from crates/shift-core/src/dependency_graph.rs rename to crates/shift-edit/src/dependency_graph.rs diff --git a/crates/shift-edit/src/edit_session.rs b/crates/shift-edit/src/edit_session.rs new file mode 100644 index 00000000..cea35759 --- /dev/null +++ b/crates/shift-edit/src/edit_session.rs @@ -0,0 +1,1180 @@ +use crate::{ + error::{CoreError, CoreResult}, + state::apply_state_to_layer, + GlyphStructure, PointId, PointType, Transform, +}; +use shift_ir::{boolean, Anchor, AnchorId, BooleanOp, Contour, ContourId, GlyphLayer, Point}; +use shift_wire::{GlyphChangedEntities, GlyphValue}; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditableNode { + Point(PointId), + Anchor(AnchorId), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct NodePosition { + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BulkNodePositionUpdates<'a> { + pub point_ids: Option<&'a [u64]>, + pub point_coords: Option<&'a [GlyphValue]>, + pub anchor_ids: Option<&'a [u64]>, + pub anchor_coords: Option<&'a [GlyphValue]>, +} + +#[derive(Default)] +struct NodeGroups { + points: Vec, + anchors: Vec, +} + +#[derive(Default)] +struct NodePositionGroups { + points: HashMap, + anchors: HashMap, +} + +struct BulkPositionUpdateReader { + groups: NodePositionGroups, +} + +impl BulkPositionUpdateReader { + fn new() -> Self { + Self { + groups: NodePositionGroups::default(), + } + } + + fn read_points( + &mut self, + ids: Option<&[u64]>, + coords: Option<&[GlyphValue]>, + ) -> CoreResult<()> { + let Some(ids) = ids else { + return Ok(()); + }; + + let coords = coords.ok_or_else(|| { + invalid_position_update_input("point positions", "missing coordinates") + })?; + let mut coords = BulkPositionCoords::new(coords, ids.len(), "point positions")?; + + for id in ids { + let (x, y) = coords.next()?; + self.groups + .points + .insert(PointId::from_raw(*id as u128), NodePosition { x, y }); + } + + coords.finish() + } + + fn read_anchors( + &mut self, + ids: Option<&[u64]>, + coords: Option<&[GlyphValue]>, + ) -> CoreResult<()> { + let Some(ids) = ids else { + return Ok(()); + }; + + let coords = coords.ok_or_else(|| { + invalid_position_update_input("anchor positions", "missing coordinates") + })?; + let mut coords = BulkPositionCoords::new(coords, ids.len(), "anchor positions")?; + + for id in ids { + let (x, y) = coords.next()?; + self.groups + .anchors + .insert(AnchorId::from_raw(*id as u128), NodePosition { x, y }); + } + + coords.finish() + } + + fn finish(self) -> NodePositionGroups { + self.groups + } +} + +struct BulkPositionCoords<'a> { + coords: &'a [GlyphValue], + index: usize, + kind: &'static str, +} + +impl<'a> BulkPositionCoords<'a> { + fn new(coords: &'a [GlyphValue], id_count: usize, kind: &'static str) -> CoreResult { + let expected_coords = id_count * 2; + if coords.len() != expected_coords { + return Err(invalid_position_update_input( + kind, + format!( + "expected {expected_coords} coordinates, got {}", + coords.len() + ), + )); + } + + Ok(Self { + coords, + index: 0, + kind, + }) + } + + fn next(&mut self) -> CoreResult<(GlyphValue, GlyphValue)> { + let x = self.read()?; + let y = self.read()?; + Ok((x, y)) + } + + fn finish(self) -> CoreResult<()> { + if self.index == self.coords.len() { + Ok(()) + } else { + Err(invalid_position_update_input( + self.kind, + format!( + "expected {} coordinates, read {}", + self.coords.len(), + self.index + ), + )) + } + } + + fn read(&mut self) -> CoreResult { + let index = self.index; + let value = self.coords.get(index).copied().ok_or_else(|| { + invalid_position_update_input(self.kind, format!("missing coordinate at index {index}")) + })?; + + self.index += 1; + Ok(value) + } +} + +fn invalid_position_update_input(kind: &'static str, message: impl Into) -> CoreError { + CoreError::InvalidPositionUpdateInput { + kind, + message: message.into(), + } +} + +pub struct EditSession { + layer: GlyphLayer, + glyph_name: String, + unicode: u32, +} + +impl EditSession { + pub fn new(name: String, unicode: u32, layer: GlyphLayer) -> Self { + Self { + layer, + glyph_name: name, + unicode, + } + } + + pub fn layer(&self) -> &GlyphLayer { + &self.layer + } + + pub fn layer_mut(&mut self) -> &mut GlyphLayer { + &mut self.layer + } + + pub fn into_layer(self) -> GlyphLayer { + self.layer + } + + pub fn glyph_name(&self) -> &str { + &self.glyph_name + } + + pub fn unicode(&self) -> u32 { + self.unicode + } + + pub fn width(&self) -> f64 { + self.layer.width() + } +} + +impl EditSession { + fn contour_mut_or_err(&mut self, id: ContourId) -> CoreResult<&mut Contour> { + let contour = self + .layer + .contour_mut(id) + .ok_or(CoreError::ContourNotFound(id))?; + + Ok(contour) + } + + fn point_mut_or_err( + &mut self, + contour_id: ContourId, + point_id: PointId, + ) -> CoreResult<&mut Point> { + let contour = self.contour_mut_or_err(contour_id)?; + contour + .get_point_mut(point_id) + .ok_or(CoreError::PointNotFound(point_id)) + } + + fn point_contour_or_err(&self, point_id: PointId) -> CoreResult { + self.find_point_contour(point_id) + .ok_or(CoreError::PointInContourNotFound(point_id)) + } + + fn point_mut_by_id_or_err(&mut self, point_id: PointId) -> CoreResult<&mut Point> { + let contour_id = self.point_contour_or_err(point_id)?; + self.point_mut_or_err(contour_id, point_id) + } + + fn anchor_mut_or_err(&mut self, anchor_id: AnchorId) -> CoreResult<&mut Anchor> { + self.layer + .anchor_mut(anchor_id) + .ok_or(CoreError::AnchorNotFound(anchor_id)) + } + + fn point_contours_or_err( + &self, + point_ids: &[PointId], + ) -> CoreResult> { + point_ids + .iter() + .map(|&point_id| { + self.point_contour_or_err(point_id) + .map(|contour_id| (point_id, contour_id)) + }) + .collect() + } + + fn points_exist_or_err(&self, point_ids: &[PointId]) -> CoreResult<()> { + for point_id in point_ids { + self.point_contour_or_err(*point_id)?; + } + Ok(()) + } + + fn point_positions_exist_or_err( + &self, + updates: &HashMap, + ) -> CoreResult<()> { + for point_id in updates.keys() { + self.point_contour_or_err(*point_id)?; + } + Ok(()) + } + + fn anchors_exist_or_err(&self, anchor_ids: &[AnchorId]) -> CoreResult<()> { + for anchor_id in anchor_ids { + if self.layer.anchor(*anchor_id).is_none() { + return Err(CoreError::AnchorNotFound(*anchor_id)); + } + } + Ok(()) + } + + fn anchor_positions_exist_or_err( + &self, + updates: &HashMap, + ) -> CoreResult<()> { + for anchor_id in updates.keys() { + if self.layer.anchor(*anchor_id).is_none() { + return Err(CoreError::AnchorNotFound(*anchor_id)); + } + } + Ok(()) + } + + fn update_points( + &mut self, + point_ids: &[PointId], + mut update: impl FnMut(&mut Point), + ) -> CoreResult<()> { + self.points_exist_or_err(point_ids)?; + + let mut remaining: HashSet = point_ids.iter().copied().collect(); + if remaining.is_empty() { + return Ok(()); + } + + for contour in self.layer.contours_iter_mut() { + for point in contour.points_mut() { + if remaining.remove(&point.id()) { + update(point); + } + } + } + + if let Some(point_id) = remaining.into_iter().next() { + Err(CoreError::PointNotFound(point_id)) + } else { + Ok(()) + } + } + + fn set_point_positions(&mut self, updates: &HashMap) -> CoreResult<()> { + self.point_positions_exist_or_err(updates)?; + + let mut remaining: HashSet = updates.keys().copied().collect(); + if remaining.is_empty() { + return Ok(()); + } + + for contour in self.layer.contours_iter_mut() { + for point in contour.points_mut() { + let point_id = point.id(); + if let Some(position) = updates.get(&point_id) { + point.set_position(position.x, position.y); + remaining.remove(&point_id); + } + } + } + + if let Some(point_id) = remaining.into_iter().next() { + Err(CoreError::PointNotFound(point_id)) + } else { + Ok(()) + } + } + + fn set_anchor_positions( + &mut self, + updates: &HashMap, + ) -> CoreResult<()> { + self.anchor_positions_exist_or_err(updates)?; + + for (anchor_id, position) in updates { + self.anchor_mut_or_err(*anchor_id)? + .set_position(position.x, position.y); + } + Ok(()) + } + + fn split_nodes(nodes: &[EditableNode]) -> NodeGroups { + let mut groups = NodeGroups::default(); + + for node in nodes { + match node { + EditableNode::Point(point_id) => groups.points.push(*point_id), + EditableNode::Anchor(anchor_id) => groups.anchors.push(*anchor_id), + } + } + + groups + } + + fn bulk_node_position_updates( + updates: BulkNodePositionUpdates<'_>, + ) -> CoreResult { + let mut reader = BulkPositionUpdateReader::new(); + reader.read_points(updates.point_ids, updates.point_coords)?; + reader.read_anchors(updates.anchor_ids, updates.anchor_coords)?; + Ok(reader.finish()) + } +} + +impl EditSession { + pub fn set_x_advance(&mut self, width: f64) { + self.layer.set_width(width); + } + + /// Translate all editable glyph geometry in the active layer. + /// + /// This moves contour points, anchors, and component transforms. + /// Glyph advance width is intentionally left unchanged. + pub fn translate_layer(&mut self, dx: f64, dy: f64) { + for contour in self.layer.contours_iter_mut() { + for point in contour.points_mut() { + point.translate(dx, dy); + } + } + + let anchor_ids: Vec<_> = self + .layer + .anchors_iter() + .map(|anchor| anchor.id()) + .collect(); + self.layer.move_anchors(&anchor_ids, dx, dy); + + let component_ids: Vec<_> = self.layer.components().keys().cloned().collect(); + for component_id in component_ids { + if let Some(mut component) = self.layer.remove_component(component_id) { + component.translate(dx, dy); + self.layer.add_component(component); + } + } + } + + pub fn restore_layer( + &mut self, + structure: &GlyphStructure, + values: &[GlyphValue], + ) -> CoreResult<()> { + let mut new_layer = self.layer.clone(); + apply_state_to_layer(&mut new_layer, structure, values)?; + self.layer = new_layer; + Ok(()) + } +} + +impl EditSession { + pub fn add_empty_contour(&mut self) -> ContourId { + let contour = Contour::new(); + let contour_id = contour.id(); + self.layer.add_contour(contour); + contour_id + } + + pub fn remove_contour(&mut self, contour_id: ContourId) -> CoreResult { + self.layer + .remove_contour(contour_id) + .ok_or(CoreError::ContourNotFound(contour_id)) + } + + pub fn close_contour(&mut self, contour_id: ContourId) -> CoreResult<()> { + let contour = self.contour_mut_or_err(contour_id)?; + contour.close(); + Ok(()) + } + + pub fn open_contour(&mut self, contour_id: ContourId) -> CoreResult<()> { + let contour = self.contour_mut_or_err(contour_id)?; + contour.open(); + Ok(()) + } + + pub fn reverse_contour(&mut self, contour_id: ContourId) -> CoreResult<()> { + let contour = self.contour_mut_or_err(contour_id)?; + contour.reverse(); + Ok(()) + } + + pub fn apply_boolean_op( + &mut self, + contour_id_a: ContourId, + contour_id_b: ContourId, + op: BooleanOp, + ) -> CoreResult> { + let a = self + .layer + .contour(contour_id_a) + .ok_or(CoreError::ContourNotFound(contour_id_a))? + .clone(); + let b = self + .layer + .contour(contour_id_b) + .ok_or(CoreError::ContourNotFound(contour_id_b))? + .clone(); + + let result = + boolean(op, &a, &b).map_err(|e| CoreError::BooleanOperationFailed(e.to_string()))?; + + self.remove_contour(contour_id_a)?; + self.remove_contour(contour_id_b)?; + + let mut created_ids = Vec::new(); + for contour in result.0 { + let id = self.layer.add_contour(contour); + created_ids.push(id); + } + + Ok(created_ids) + } + + pub fn find_point_contour(&self, point_id: PointId) -> Option { + for contour in self.layer.contours_iter() { + if contour.get_point(point_id).is_some() { + return Some(contour.id()); + } + } + None + } +} + +impl EditSession { + pub fn add_point_to_contour( + &mut self, + contour_id: ContourId, + x: f64, + y: f64, + point_type: PointType, + is_smooth: bool, + ) -> CoreResult { + let contour = self.contour_mut_or_err(contour_id)?; + let point_id = contour.add_point(x, y, point_type, is_smooth); + + Ok(point_id) + } + + pub fn insert_point_before( + &mut self, + before_id: PointId, + x: f64, + y: f64, + point_type: PointType, + is_smooth: bool, + ) -> CoreResult { + let contour_id = self.point_contour_or_err(before_id)?; + let contour = self.contour_mut_or_err(contour_id)?; + + let point_id = contour + .insert_point_before(before_id, x, y, point_type, is_smooth) + .ok_or(CoreError::PointNotFound(before_id))?; + + Ok(point_id) + } + + pub fn remove_point(&mut self, point_id: PointId) -> CoreResult<()> { + let contour_id = self.point_contour_or_err(point_id)?; + let contour = self.contour_mut_or_err(contour_id)?; + contour + .remove_point(point_id) + .ok_or(CoreError::PointNotFound(point_id))?; + Ok(()) + } + + pub fn move_points(&mut self, point_ids: &[PointId], dx: f64, dy: f64) -> CoreResult<()> { + self.update_points(point_ids, |point| point.translate(dx, dy)) + } + + pub fn transform_points( + &mut self, + point_ids: &[PointId], + transform: Transform, + ) -> CoreResult<()> { + self.update_points(point_ids, |point| { + let (x, y) = transform.transform_point(point.x(), point.y()); + point.set_position(x, y); + }) + } + + /// Set absolute position for a single point + pub fn set_point_position(&mut self, point_id: PointId, x: f64, y: f64) -> CoreResult<()> { + self.move_point(point_id, x, y) + } + + pub fn move_point(&mut self, point_id: PointId, x: f64, y: f64) -> CoreResult<()> { + self.point_mut_by_id_or_err(point_id)?.set_position(x, y); + Ok(()) + } + + pub fn translate_point(&mut self, point_id: PointId, dx: f64, dy: f64) -> CoreResult<()> { + self.point_mut_by_id_or_err(point_id)?.translate(dx, dy); + Ok(()) + } + + pub fn toggle_smooth(&mut self, point_id: PointId) -> CoreResult<()> { + let contour_id = self.point_contour_or_err(point_id)?; + let point = self.point_mut_or_err(contour_id, point_id)?; + + point.toggle_smooth(); + + Ok(()) + } + + pub fn remove_points(&mut self, point_ids: &[PointId]) -> CoreResult<()> { + let point_contours = self.point_contours_or_err(point_ids)?; + + for (point_id, contour_id) in point_contours { + let contour = self.contour_mut_or_err(contour_id)?; + contour + .remove_point(point_id) + .ok_or(CoreError::PointNotFound(point_id))?; + } + + Ok(()) + } +} + +impl EditSession { + /// Set absolute position for a single anchor + pub fn set_anchor_position(&mut self, anchor_id: AnchorId, x: f64, y: f64) -> CoreResult<()> { + self.anchor_mut_or_err(anchor_id)?.set_position(x, y); + Ok(()) + } + + pub fn move_anchors(&mut self, anchor_ids: &[AnchorId], dx: f64, dy: f64) -> CoreResult<()> { + self.anchors_exist_or_err(anchor_ids)?; + + for anchor_id in anchor_ids { + self.anchor_mut_or_err(*anchor_id)?.translate(dx, dy); + } + Ok(()) + } + + pub fn transform_anchors( + &mut self, + anchor_ids: &[AnchorId], + transform: Transform, + ) -> CoreResult<()> { + self.anchors_exist_or_err(anchor_ids)?; + + for anchor_id in anchor_ids { + let anchor = self.anchor_mut_or_err(*anchor_id)?; + let (x, y) = transform.transform_point(anchor.x(), anchor.y()); + anchor.set_position(x, y); + } + Ok(()) + } + + pub fn move_nodes(&mut self, nodes: &[EditableNode], dx: f64, dy: f64) -> CoreResult<()> { + let groups = Self::split_nodes(nodes); + self.points_exist_or_err(&groups.points)?; + self.anchors_exist_or_err(&groups.anchors)?; + + self.move_points(&groups.points, dx, dy)?; + self.move_anchors(&groups.anchors, dx, dy) + } + + pub fn transform_nodes( + &mut self, + nodes: &[EditableNode], + transform: Transform, + ) -> CoreResult<()> { + let groups = Self::split_nodes(nodes); + self.points_exist_or_err(&groups.points)?; + self.anchors_exist_or_err(&groups.anchors)?; + + self.transform_points(&groups.points, transform)?; + self.transform_anchors(&groups.anchors, transform) + } + + pub fn set_bulk_node_positions( + &mut self, + updates: BulkNodePositionUpdates<'_>, + ) -> CoreResult { + let groups = Self::bulk_node_position_updates(updates)?; + let changed = GlyphChangedEntities { + point_ids: groups.points.keys().copied().collect(), + anchor_ids: groups.anchors.keys().copied().collect(), + ..Default::default() + }; + + self.point_positions_exist_or_err(&groups.points)?; + self.anchor_positions_exist_or_err(&groups.anchors)?; + + self.set_point_positions(&groups.points)?; + self.set_anchor_positions(&groups.anchors)?; + Ok(changed) + } +} + +impl EditSession { + pub fn contour(&self, id: ContourId) -> Option<&Contour> { + self.layer.contour(id) + } + + pub fn contour_mut(&mut self, id: ContourId) -> Option<&mut Contour> { + self.layer.contour_mut(id) + } + + pub fn contours_iter(&self) -> impl Iterator { + self.layer.contours_iter() + } + + pub fn contours_count(&self) -> usize { + self.layer.contours().len() + } +} + +impl EditSession { + pub fn paste_contours( + &mut self, + contours: Vec, + offset_x: f64, + offset_y: f64, + ) -> PasteResult { + let mut created_point_ids = Vec::new(); + let mut created_contour_ids = Vec::new(); + + for paste_contour in contours { + let mut contour = Contour::new(); + + for point in paste_contour.points { + let point_id = contour.add_point( + point.x + offset_x, + point.y + offset_y, + point.point_type, + point.smooth, + ); + created_point_ids.push(point_id); + } + + if paste_contour.closed { + contour.close(); + } + + let contour_id = self.layer.add_contour(contour); + created_contour_ids.push(contour_id); + } + + PasteResult { + created_point_ids, + created_contour_ids, + } + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PastePoint { + pub x: f64, + pub y: f64, + pub point_type: PointType, + pub smooth: bool, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasteContour { + pub points: Vec, + pub closed: bool, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasteResult { + pub created_point_ids: Vec, + pub created_contour_ids: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use shift_ir::{Anchor, Component}; + + fn create_session() -> EditSession { + EditSession::new("test".to_string(), 65, GlyphLayer::with_width(500.0)) + } + + fn session_with_contour() -> (EditSession, ContourId) { + let mut session = create_session(); + let contour_id = session.add_empty_contour(); + (session, contour_id) + } + + fn add_point(session: &mut EditSession, contour_id: ContourId, x: f64, y: f64) -> PointId { + session + .add_point_to_contour(contour_id, x, y, PointType::OnCurve, false) + .unwrap() + } + + fn add_anchor(session: &mut EditSession, x: f64, y: f64) -> AnchorId { + session + .layer_mut() + .add_anchor(Anchor::new(Some("top".to_string()), x, y)) + } + + fn point_position( + session: &EditSession, + contour_id: ContourId, + point_id: PointId, + ) -> (f64, f64) { + let point = session + .contour(contour_id) + .unwrap() + .get_point(point_id) + .unwrap(); + (point.x(), point.y()) + } + + fn anchor_position(session: &EditSession, anchor_id: AnchorId) -> (f64, f64) { + let anchor = session.layer().anchor(anchor_id).unwrap(); + (anchor.x(), anchor.y()) + } + + #[test] + fn remove_contour_removes_contour() { + let (mut session, contour_id) = session_with_contour(); + + session.remove_contour(contour_id).unwrap(); + + assert_eq!(session.contours_count(), 0); + } + + #[test] + fn remove_contour_missing_returns_typed_error() { + let mut session = create_session(); + let contour_id = ContourId::new(); + + assert!(matches!( + session.remove_contour(contour_id), + Err(CoreError::ContourNotFound(id)) if id == contour_id + )); + } + + #[test] + fn move_nodes_moves_points_and_anchors() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 10.0, 20.0); + let anchor_id = add_anchor(&mut session, 100.0, 200.0); + + session + .move_nodes( + &[ + EditableNode::Point(point_id), + EditableNode::Anchor(anchor_id), + ], + 5.0, + -10.0, + ) + .unwrap(); + + assert_eq!(point_position(&session, contour_id, point_id), (15.0, 10.0)); + assert_eq!(anchor_position(&session, anchor_id), (105.0, 190.0)); + } + + #[test] + fn transform_nodes_applies_affine_transform_to_points_and_anchors() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 10.0, 20.0); + let anchor_id = add_anchor(&mut session, 100.0, 200.0); + + session + .transform_nodes( + &[ + EditableNode::Point(point_id), + EditableNode::Anchor(anchor_id), + ], + Transform { + xx: 2.0, + xy: 0.0, + yx: 0.0, + yy: 3.0, + dx: 5.0, + dy: -10.0, + }, + ) + .unwrap(); + + assert_eq!(point_position(&session, contour_id, point_id), (25.0, 50.0)); + assert_eq!(anchor_position(&session, anchor_id), (205.0, 590.0)); + } + + #[test] + fn set_bulk_node_positions_sets_points_and_anchors() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 10.0, 20.0); + let anchor_id = add_anchor(&mut session, 100.0, 200.0); + let point_ids = [point_id.raw()]; + let point_coords = [300.0, 400.0]; + let anchor_ids = [anchor_id.raw()]; + let anchor_coords = [500.0, 600.0]; + + session + .set_bulk_node_positions(BulkNodePositionUpdates { + point_ids: Some(&point_ids), + point_coords: Some(&point_coords), + anchor_ids: Some(&anchor_ids), + anchor_coords: Some(&anchor_coords), + }) + .unwrap(); + + assert_eq!( + point_position(&session, contour_id, point_id), + (300.0, 400.0) + ); + assert_eq!(anchor_position(&session, anchor_id), (500.0, 600.0)); + } + + #[test] + fn move_point_sets_absolute_position() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 0.0, 0.0); + + session.move_point(point_id, 50.0, 75.0).unwrap(); + + assert_eq!(point_position(&session, contour_id, point_id), (50.0, 75.0)); + } + + #[test] + fn move_point_missing_returns_typed_error() { + let mut session = create_session(); + let point_id = PointId::new(); + + assert!(matches!( + session.move_point(point_id, 50.0, 75.0), + Err(CoreError::PointInContourNotFound(id)) if id == point_id + )); + } + + #[test] + fn add_point_to_missing_contour_returns_typed_error() { + let mut session = create_session(); + let contour_id = ContourId::new(); + + assert!(matches!( + session.add_point_to_contour(contour_id, 1.0, 2.0, PointType::OnCurve, false), + Err(CoreError::ContourNotFound(id)) if id == contour_id + )); + } + + #[test] + fn move_points_multiple() { + let (mut session, contour_id) = session_with_contour(); + let p1 = add_point(&mut session, contour_id, 0.0, 0.0); + let p2 = add_point(&mut session, contour_id, 100.0, 100.0); + + session.move_points(&[p1, p2], 10.0, 20.0).unwrap(); + + assert_eq!(point_position(&session, contour_id, p1), (10.0, 20.0)); + assert_eq!(point_position(&session, contour_id, p2), (110.0, 120.0)); + } + + #[test] + fn move_points_across_contours() { + let mut session = create_session(); + let c1_id = session.add_empty_contour(); + let p1 = session + .add_point_to_contour(c1_id, 0.0, 0.0, PointType::OnCurve, false) + .unwrap(); + + let c2_id = session.add_empty_contour(); + let p2 = session + .add_point_to_contour(c2_id, 50.0, 50.0, PointType::OnCurve, false) + .unwrap(); + + session.move_points(&[p1, p2], 5.0, 5.0).unwrap(); + + let c1 = session.contour(c1_id).unwrap(); + let c2 = session.contour(c2_id).unwrap(); + + assert_eq!(c1.get_point(p1).unwrap().x(), 5.0); + assert_eq!(c2.get_point(p2).unwrap().x(), 55.0); + } + + #[test] + fn move_points_missing_point_does_not_partially_move_existing_points() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 0.0, 0.0); + let missing_id = PointId::new(); + + let result = session.move_points(&[point_id, missing_id], 10.0, 20.0); + + assert!(matches!( + result, + Err(CoreError::PointInContourNotFound(id)) if id == missing_id + )); + assert_eq!(point_position(&session, contour_id, point_id), (0.0, 0.0)); + } + + #[test] + fn move_anchors_missing_anchor_does_not_partially_move_existing_anchors() { + let mut session = create_session(); + let anchor_id = add_anchor(&mut session, 10.0, 20.0); + let missing_id = AnchorId::new(); + + let result = session.move_anchors(&[anchor_id, missing_id], 5.0, 6.0); + + assert!(matches!( + result, + Err(CoreError::AnchorNotFound(id)) if id == missing_id + )); + assert_eq!(anchor_position(&session, anchor_id), (10.0, 20.0)); + } + + #[test] + fn move_nodes_missing_anchor_does_not_move_points() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 10.0, 20.0); + let missing_id = AnchorId::new(); + + let result = session.move_nodes( + &[ + EditableNode::Point(point_id), + EditableNode::Anchor(missing_id), + ], + 5.0, + 6.0, + ); + + assert!(matches!( + result, + Err(CoreError::AnchorNotFound(id)) if id == missing_id + )); + assert_eq!(point_position(&session, contour_id, point_id), (10.0, 20.0)); + } + + #[test] + fn set_bulk_node_positions_missing_anchor_does_not_move_points() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 10.0, 20.0); + let missing_id = AnchorId::new(); + let point_ids = [point_id.raw()]; + let point_coords = [300.0, 400.0]; + let anchor_ids = [missing_id.raw()]; + let anchor_coords = [500.0, 600.0]; + + let result = session.set_bulk_node_positions(BulkNodePositionUpdates { + point_ids: Some(&point_ids), + point_coords: Some(&point_coords), + anchor_ids: Some(&anchor_ids), + anchor_coords: Some(&anchor_coords), + }); + + assert!(matches!( + result, + Err(CoreError::AnchorNotFound(id)) if id == missing_id + )); + assert_eq!(point_position(&session, contour_id, point_id), (10.0, 20.0)); + } + + #[test] + fn translate_layer_moves_points_and_anchors_without_changing_width() { + let mut session = create_session(); + let original_width = session.width(); + let contour_id = session.add_empty_contour(); + let point_id = session + .add_point_to_contour(contour_id, 10.0, 20.0, PointType::OnCurve, false) + .unwrap(); + let anchor_id = + session + .layer_mut() + .add_anchor(Anchor::new(Some("top".to_string()), 30.0, 40.0)); + + session.translate_layer(5.0, -3.0); + + let point = session + .contour(contour_id) + .unwrap() + .get_point(point_id) + .unwrap(); + let anchor = session.layer().anchor(anchor_id).unwrap(); + assert_eq!(point.x(), 15.0); + assert_eq!(point.y(), 17.0); + assert_eq!(anchor.x(), 35.0); + assert_eq!(anchor.y(), 37.0); + assert_eq!(session.width(), original_width); + } + + #[test] + fn translate_layer_moves_component_transforms() { + let mut session = create_session(); + let component_id = session + .layer_mut() + .add_component(Component::new("base".to_string())); + + session.translate_layer(12.0, -7.0); + + let component = session.layer().component(component_id).unwrap(); + let matrix = component.matrix(); + assert_eq!(matrix.dx, 12.0); + assert_eq!(matrix.dy, -7.0); + } + + #[test] + fn remove_points_removes_all_requested_points() { + let (mut session, contour_id) = session_with_contour(); + let p1 = add_point(&mut session, contour_id, 0.0, 0.0); + let p2 = add_point(&mut session, contour_id, 100.0, 100.0); + let p3 = add_point(&mut session, contour_id, 200.0, 200.0); + + session.remove_points(&[p1, p3]).unwrap(); + + assert!(session.find_point_contour(p2).is_some()); + assert!(session.find_point_contour(p1).is_none()); + assert!(session.find_point_contour(p3).is_none()); + } + + #[test] + fn remove_points_missing_point_does_not_partially_remove_existing_points() { + let (mut session, contour_id) = session_with_contour(); + let point_id = add_point(&mut session, contour_id, 0.0, 0.0); + let missing_id = PointId::new(); + + let result = session.remove_points(&[point_id, missing_id]); + + assert!(matches!( + result, + Err(CoreError::PointInContourNotFound(id)) if id == missing_id + )); + assert!(session.find_point_contour(point_id).is_some()); + } + + #[test] + fn insert_point_before_creates_bezier_pattern() { + let (mut session, contour_id) = session_with_contour(); + let anchor1 = add_point(&mut session, contour_id, 0.0, 0.0); + let anchor2 = add_point(&mut session, contour_id, 100.0, 100.0); + + let control = session + .insert_point_before(anchor2, 50.0, 75.0, PointType::OffCurve, false) + .unwrap(); + + let contour = session.contour(contour_id).unwrap(); + let points: Vec<_> = contour.points().iter().collect(); + + assert_eq!(points.len(), 3); + assert_eq!(points[0].id(), anchor1); + assert_eq!(points[1].id(), control); + assert_eq!(points[2].id(), anchor2); + + assert_eq!(points[0].point_type(), PointType::OnCurve); + assert_eq!(points[1].point_type(), PointType::OffCurve); + assert_eq!(points[2].point_type(), PointType::OnCurve); + } + + #[test] + fn insert_point_before_nonexistent_fails() { + let (mut session, contour_id) = session_with_contour(); + add_point(&mut session, contour_id, 0.0, 0.0); + let fake_id = PointId::new(); + + assert!(matches!( + session.insert_point_before(fake_id, 50.0, 50.0, PointType::OffCurve, false), + Err(CoreError::PointInContourNotFound(id)) if id == fake_id + )); + } + + #[test] + fn paste_contours_creates_offset_contours_and_points() { + let mut session = create_session(); + + let result = session.paste_contours( + vec![PasteContour { + points: vec![ + PastePoint { + x: 10.0, + y: 20.0, + point_type: PointType::OnCurve, + smooth: false, + }, + PastePoint { + x: 30.0, + y: 40.0, + point_type: PointType::OffCurve, + smooth: true, + }, + ], + closed: true, + }], + 5.0, + -10.0, + ); + + let contour_id = result.created_contour_ids[0]; + let contour = session.contour(contour_id).unwrap(); + + assert!(contour.is_closed()); + assert_eq!(result.created_point_ids.len(), 2); + assert_eq!( + point_position(&session, contour_id, result.created_point_ids[0]), + (15.0, 10.0) + ); + assert_eq!( + point_position(&session, contour_id, result.created_point_ids[1]), + (35.0, 30.0) + ); + } +} diff --git a/crates/shift-edit/src/error.rs b/crates/shift-edit/src/error.rs new file mode 100644 index 00000000..e9d1676a --- /dev/null +++ b/crates/shift-edit/src/error.rs @@ -0,0 +1,42 @@ +use shift_ir::{AnchorId, ContourId, PointId}; + +#[derive(Debug, thiserror::Error)] +pub enum CoreError { + #[error("point {0} not found")] + PointNotFound(PointId), + + #[error("invalid contour id {0}")] + InvalidContourId(String), + + #[error("invalid point id {0}")] + InvalidPointId(String), + + #[error("invalid anchor id {0}")] + InvalidAnchorId(String), + + #[error("invalid component id {0}")] + InvalidComponentId(String), + + #[error("contour {0} not found")] + ContourNotFound(ContourId), + + #[error("point {0} not found in any contour")] + PointInContourNotFound(PointId), + + #[error("anchor {0} not found")] + AnchorNotFound(AnchorId), + + #[error("boolean operation failed: {0}")] + BooleanOperationFailed(String), + + #[error("missing glyph value at {index}")] + MissingGlyphValue { index: usize }, + + #[error("trailing glyph values: expected {expected}, got {actual}")] + TrailingGlyphValues { expected: usize, actual: usize }, + + #[error("invalid {kind}: {message}")] + InvalidPositionUpdateInput { kind: &'static str, message: String }, +} + +pub type CoreResult = Result; diff --git a/crates/shift-edit/src/interpolation.rs b/crates/shift-edit/src/interpolation.rs new file mode 100644 index 00000000..425f7999 --- /dev/null +++ b/crates/shift-edit/src/interpolation.rs @@ -0,0 +1,216 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use fontdrasil::coords::{NormalizedCoord, NormalizedLocation}; +use fontdrasil::types::Tag; +use fontdrasil::variations::VariationModel; +use shift_ir::Axis; +use shift_wire::{ + values_from_layer, AxisTent, GlyphMaster, GlyphStructure, GlyphVariationData, Location, +}; + +use crate::{Font, Glyph}; + +#[derive(Debug, Clone)] +pub struct SourceError { + pub source_index: usize, + pub source_name: String, + pub message: String, +} + +fn check_compatibility(a: &GlyphStructure, b: &GlyphStructure) -> Result<(), String> { + // Contour topology must match so point values line up by index. + if a.contours.len() != b.contours.len() { + return Err(format!( + "contour count mismatch: {} vs {}", + a.contours.len(), + b.contours.len() + )); + } + + for (i, (ca, cb)) in a.contours.iter().zip(b.contours.iter()).enumerate() { + if ca.closed != cb.closed { + return Err(format!( + "contour {i} closed mismatch: {} vs {}", + ca.closed, cb.closed + )); + } + + if ca.points.len() != cb.points.len() { + return Err(format!( + "contour {} point count mismatch: {} vs {}", + i, + ca.points.len(), + cb.points.len() + )); + } + + for (j, (pa, pb)) in ca.points.iter().zip(cb.points.iter()).enumerate() { + if pa.point_type != pb.point_type { + return Err(format!("contour {i} point {j} type mismatch")); + } + if pa.smooth != pb.smooth { + return Err(format!( + "contour {i} point {j} smooth mismatch: {} vs {}", + pa.smooth, pb.smooth + )); + } + } + } + + // Anchors contribute x/y values, so names and order must agree. + if a.anchors.len() != b.anchors.len() { + return Err(format!( + "anchor count mismatch: {} vs {}", + a.anchors.len(), + b.anchors.len() + )); + } + + for (i, (aa, ab)) in a.anchors.iter().zip(b.anchors.iter()).enumerate() { + if aa.name != ab.name { + return Err(format!("anchor {i} name mismatch")); + } + } + + // Components contribute transform values, keyed structurally by base glyph. + if a.components.len() != b.components.len() { + return Err(format!( + "component count mismatch: {} vs {}", + a.components.len(), + b.components.len() + )); + } + + for (i, (ca, cb)) in a.components.iter().zip(b.components.iter()).enumerate() { + if ca.base_glyph_name != cb.base_glyph_name { + return Err(format!( + "component {i} base glyph mismatch: {} vs {}", + ca.base_glyph_name, cb.base_glyph_name + )); + } + } + + Ok(()) +} + +fn to_fd_wire_location(location: &Location, axes: &[Axis]) -> NormalizedLocation { + let mut result = NormalizedLocation::new(); + + for axis in axes { + let value = location + .values + .get(axis.tag()) + .copied() + .unwrap_or(axis.default()); + let normalized = axis.normalize(value); + let Ok(tag) = Tag::from_str(axis.tag()) else { + continue; + }; + + result.insert(tag, NormalizedCoord::new(normalized)); + } + + result +} + +/// Build per-source masters for a single glyph. +/// +/// Pure: no editing-session knowledge. Caller passes the `Glyph` it wants the +/// masters from (could be the committed copy or an in-progress editing copy +/// with the live session layer patched in by the bridge). +/// +/// Returns `None` if the font isn't variable or no source has a non-empty layer +/// for this glyph. +pub fn build_masters(font: &Font, glyph: &Glyph) -> Option> { + if !font.is_variable() { + return None; + } + + let default_source_id = font.default_source_id(); + let mut masters: Vec = Vec::new(); + + for source in font.sources() { + let layer = match glyph.layer(source.layer_id()) { + Some(layer) + if !layer.contours().is_empty() + || !layer.anchors().is_empty() + || !layer.components().is_empty() => + { + layer + } + _ => continue, + }; + + let structure = GlyphStructure::from(layer); + let values = values_from_layer(layer); + + masters.push(GlyphMaster { + source_id: source.id().to_string(), + source_name: source.name().to_string(), + is_default_source: default_source_id == Some(source.id()), + location: source.location().into(), + structure, + values, + }); + } + + if masters.is_empty() { + None + } else { + Some(masters) + } +} + +pub fn get_glyph_variation_data( + masters: &[GlyphMaster], + axes: &[Axis], +) -> Option { + let ordered_axes: Vec = axes + .iter() + .filter_map(|a| Tag::from_str(a.tag()).ok()) + .collect(); + + let default_master = masters.iter().find(|master| master.is_default_source)?; + + let mut errors = Vec::new(); + let mut points: HashMap> = HashMap::new(); + for (source_index, master) in masters.iter().enumerate() { + match check_compatibility(&master.structure, &default_master.structure) { + Ok(()) => { + let loc = to_fd_wire_location(&master.location, axes); + points.insert(loc, master.values.clone()); + } + Err(message) => { + errors.push(SourceError { + source_index, + source_name: master.source_name.clone(), + message, + }); + } + } + } + + let locations_set: HashSet = points.keys().cloned().collect(); + let model = VariationModel::new(locations_set, ordered_axes); + let model_deltas = model.deltas::(&points).ok()?; + + let regions: Vec> = model_deltas + .iter() + .map(|(region, _)| { + region + .iter() + .map(|(tag, tent)| AxisTent { + axis_tag: tag.to_string(), + lower: tent.min.into_inner().into_inner(), + peak: tent.peak.into_inner().into_inner(), + upper: tent.max.into_inner().into_inner(), + }) + .collect() + }) + .collect(); + + let deltas: Vec> = model_deltas.into_iter().map(|(_, d)| d).collect(); + + Some(GlyphVariationData { regions, deltas }) +} diff --git a/crates/shift-core/src/lib.rs b/crates/shift-edit/src/lib.rs similarity index 57% rename from crates/shift-core/src/lib.rs rename to crates/shift-edit/src/lib.rs index ae75a7dd..64b8136e 100644 --- a/crates/shift-core/src/lib.rs +++ b/crates/shift-edit/src/lib.rs @@ -1,21 +1,27 @@ -pub mod binary; pub mod composite; -pub mod constants; pub mod curve; pub mod dependency_graph; pub mod edit_session; -pub mod font_loader; +pub mod error; pub mod interpolation; -pub mod snapshot; +pub mod state; pub mod vec2; +pub use shift_wire::{ + values_from_layer, AnchorData, ComponentData, ContourData, GlyphMaster, GlyphState, + GlyphStructure, GlyphStructureChange, GlyphValueChange, GlyphVariationData, PointData, +}; + pub use shift_ir::{ Anchor, AnchorId, Axis, BooleanOp, Contour, ContourId, CurveSegment, CurveSegmentIter, Font, FontMetadata, FontMetrics, Glyph, GlyphLayer, GlyphName, GuidelineId, LayerId, Location, Point, PointId, PointType, Source, SourceId, Transform, }; +pub use shift_backends::font_loader; pub use shift_backends::ufo::{UfoReader, UfoWriter}; pub use shift_backends::{FontBackend, FontReader, FontWriter}; -pub use edit_session::{NodePositionUpdate, NodeRef, PasteContour, PastePoint, PasteResult}; +pub use edit_session::{ + BulkNodePositionUpdates, EditableNode, PasteContour, PastePoint, PasteResult, +}; diff --git a/crates/shift-edit/src/state.rs b/crates/shift-edit/src/state.rs new file mode 100644 index 00000000..02c4dab3 --- /dev/null +++ b/crates/shift-edit/src/state.rs @@ -0,0 +1,333 @@ +//! Strict restore helpers for the shared glyph state wire format. + +use std::str::FromStr; + +use crate::error::{CoreError, CoreResult}; +use shift_ir::{ + Anchor as IrAnchor, AnchorId, Component as IrComponent, ComponentId, Contour as IrContour, + ContourId, DecomposedTransform as IrTransform, GlyphLayer, PointId, PointType as IrPointType, +}; +use shift_wire::{AnchorData, ComponentData, ContourData, GlyphStructure, GlyphValue}; + +pub fn layer_from_state( + structure: &GlyphStructure, + values: &[GlyphValue], +) -> CoreResult { + let mut layer = GlyphLayer::new(); + apply_state_to_layer(&mut layer, structure, values)?; + Ok(layer) +} + +pub fn apply_state_to_layer( + layer: &mut GlyphLayer, + structure: &GlyphStructure, + values: &[GlyphValue], +) -> CoreResult<()> { + let mut cursor = GlyphValueCursor::new(values); + let width = cursor.read_x_advance()?; + + layer.clear_contours(); + layer.clear_anchors(); + layer.clear_components(); + layer.set_width(width); + + restore_contours(layer, &structure.contours, &mut cursor)?; + restore_anchors(layer, &structure.anchors, &mut cursor)?; + restore_components(layer, &structure.components, &mut cursor)?; + + cursor.finish()?; + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +struct PointPosition { + x: GlyphValue, + y: GlyphValue, +} + +struct GlyphValueCursor<'a> { + values: &'a [GlyphValue], + index: usize, +} + +impl<'a> GlyphValueCursor<'a> { + fn new(values: &'a [GlyphValue]) -> Self { + Self { values, index: 0 } + } + + fn read_x_advance(&mut self) -> CoreResult { + self.next() + } + + fn read_point(&mut self) -> CoreResult { + Ok(PointPosition { + x: self.next()?, + y: self.next()?, + }) + } + + fn read_component_transform(&mut self) -> CoreResult { + Ok(IrTransform { + translate_x: self.next()?, + translate_y: self.next()?, + rotation: self.next()?, + scale_x: self.next()?, + scale_y: self.next()?, + skew_x: self.next()?, + skew_y: self.next()?, + t_center_x: self.next()?, + t_center_y: self.next()?, + }) + } + + fn finish(self) -> CoreResult<()> { + if self.index == self.values.len() { + Ok(()) + } else { + Err(CoreError::TrailingGlyphValues { + expected: self.index, + actual: self.values.len(), + }) + } + } + + fn next(&mut self) -> CoreResult { + let idx = self.index; + let value = self + .values + .get(idx) + .copied() + .ok_or(CoreError::MissingGlyphValue { index: idx })?; + + self.index += 1; + Ok(value) + } +} + +fn parse_id(value: &str, invalid: impl FnOnce(String) -> CoreError) -> CoreResult +where + T: FromStr, +{ + value.parse().map_err(|_| invalid(value.to_string())) +} + +fn restore_contours( + layer: &mut GlyphLayer, + contours: &[ContourData], + cursor: &mut GlyphValueCursor<'_>, +) -> CoreResult<()> { + for contour_data in contours { + let contour_id: ContourId = parse_id(&contour_data.id, CoreError::InvalidContourId)?; + let mut new_contour = IrContour::with_id(contour_id); + + for point in &contour_data.points { + let point_id: PointId = parse_id(&point.id, CoreError::InvalidPointId)?; + let new_pos = cursor.read_point()?; + let point_type = IrPointType::from(point.point_type); + new_contour.add_point_with_id(point_id, new_pos.x, new_pos.y, point_type, point.smooth); + } + + if contour_data.closed { + new_contour.close(); + } + + layer.add_contour(new_contour); + } + + Ok(()) +} + +fn restore_anchors( + layer: &mut GlyphLayer, + anchors: &[AnchorData], + cursor: &mut GlyphValueCursor<'_>, +) -> CoreResult<()> { + for anchor_data in anchors { + let anchor_id: AnchorId = parse_id(&anchor_data.id, CoreError::InvalidAnchorId)?; + let position = cursor.read_point()?; + let anchor = IrAnchor::with_id(anchor_id, anchor_data.name.clone(), position.x, position.y); + + layer.add_anchor(anchor); + } + + Ok(()) +} + +fn restore_components( + layer: &mut GlyphLayer, + components: &[ComponentData], + cursor: &mut GlyphValueCursor<'_>, +) -> CoreResult<()> { + for component_data in components { + let component_id: ComponentId = + parse_id(&component_data.id, CoreError::InvalidComponentId)?; + let transform = cursor.read_component_transform()?; + let component = IrComponent::with_id( + component_id, + component_data.base_glyph_name.clone(), + transform, + ); + + layer.add_component(component); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use shift_ir::{Anchor, Component, DecomposedTransform}; + use shift_wire::values_from_layer; + + fn sample_layer() -> GlyphLayer { + let mut layer = GlyphLayer::with_width(500.0); + + let mut contour = IrContour::with_id(ContourId::from_raw(10)); + contour.add_point_with_id(PointId::from_raw(20), 1.0, 2.0, IrPointType::OnCurve, false); + contour.add_point_with_id(PointId::from_raw(21), 3.0, 4.0, IrPointType::OffCurve, true); + contour.close(); + layer.add_contour(contour); + + layer.add_anchor(Anchor::with_id( + AnchorId::from_raw(30), + Some("top".to_string()), + 5.0, + 6.0, + )); + + layer.add_component(Component::with_id( + ComponentId::from_raw(40), + "base".to_string(), + DecomposedTransform { + translate_x: 7.0, + translate_y: 8.0, + rotation: 9.0, + scale_x: 10.0, + scale_y: 11.0, + skew_x: 12.0, + skew_y: 13.0, + t_center_x: 14.0, + t_center_y: 15.0, + }, + )); + + layer + } + + #[test] + fn values_from_layer_uses_canonical_order() { + let layer = sample_layer(); + + assert_eq!( + values_from_layer(&layer), + vec![ + 500.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, + 15.0, + ] + ); + } + + #[test] + fn layer_from_state_restores_ids_structure_and_values() -> CoreResult<()> { + let layer = sample_layer(); + let structure = GlyphStructure::from(&layer); + let restored = layer_from_state(&structure, &values_from_layer(&layer))?; + + assert_eq!(restored.width(), 500.0); + + let contour = restored.contour(ContourId::from_raw(10)).unwrap(); + assert!(contour.is_closed()); + assert_eq!(contour.points().len(), 2); + + let first = contour.get_point(PointId::from_raw(20)).unwrap(); + assert_eq!((first.x(), first.y()), (1.0, 2.0)); + assert_eq!(first.point_type(), IrPointType::OnCurve); + assert!(!first.is_smooth()); + + let second = contour.get_point(PointId::from_raw(21)).unwrap(); + assert_eq!((second.x(), second.y()), (3.0, 4.0)); + assert_eq!(second.point_type(), IrPointType::OffCurve); + assert!(second.is_smooth()); + + let anchor = restored.anchor(AnchorId::from_raw(30)).unwrap(); + assert_eq!(anchor.name(), Some("top")); + assert_eq!(anchor.position(), (5.0, 6.0)); + + let component = restored.component(ComponentId::from_raw(40)).unwrap(); + assert_eq!(component.base_glyph().as_str(), "base"); + assert_eq!(component.transform().translate_x, 7.0); + assert_eq!(component.transform().t_center_y, 15.0); + + Ok(()) + } + + #[test] + fn glyph_structure_sorts_components_by_id() { + let mut layer = GlyphLayer::new(); + layer.add_component(Component::with_id( + ComponentId::from_raw(200), + "later".to_string(), + DecomposedTransform::identity(), + )); + layer.add_component(Component::with_id( + ComponentId::from_raw(100), + "earlier".to_string(), + DecomposedTransform::identity(), + )); + + let structure = GlyphStructure::from(&layer); + + assert_eq!(structure.components[0].id, "100"); + assert_eq!(structure.components[1].id, "200"); + } + + #[test] + fn layer_from_state_rejects_missing_values() { + let structure = GlyphStructure { + contours: vec![], + anchors: vec![], + components: vec![], + }; + + assert!(matches!( + layer_from_state(&structure, &[]), + Err(CoreError::MissingGlyphValue { index: 0 }) + )); + } + + #[test] + fn layer_from_state_rejects_trailing_values() { + let structure = GlyphStructure { + contours: vec![], + anchors: vec![], + components: vec![], + }; + + assert!(matches!( + layer_from_state(&structure, &[500.0, 1.0]), + Err(CoreError::TrailingGlyphValues { + expected: 1, + actual: 2, + }) + )); + } + + #[test] + fn layer_from_state_rejects_invalid_contour_ids() { + let structure = GlyphStructure { + contours: vec![ContourData { + id: "not-a-contour-id".to_string(), + points: vec![], + closed: false, + }], + anchors: vec![], + components: vec![], + }; + + assert!(matches!( + layer_from_state(&structure, &[500.0]), + Err(CoreError::InvalidContourId(value)) if value == "not-a-contour-id" + )); + } +} diff --git a/crates/shift-core/src/vec2.rs b/crates/shift-edit/src/vec2.rs similarity index 100% rename from crates/shift-core/src/vec2.rs rename to crates/shift-edit/src/vec2.rs diff --git a/crates/shift-ir/Cargo.toml b/crates/shift-ir/Cargo.toml index 706c6f48..7d40b0b8 100644 --- a/crates/shift-ir/Cargo.toml +++ b/crates/shift-ir/Cargo.toml @@ -13,5 +13,4 @@ fontdrasil = "0.4.0" indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" -serde = { version = "1.0", features = ["derive"] } -ts-rs = "11.0.1" +serde = { version = "1.0", features = ["derive", "rc"] } diff --git a/crates/shift-ir/docs/DOCS.md b/crates/shift-ir/docs/DOCS.md index a01c8b31..8a15660e 100644 --- a/crates/shift-ir/docs/DOCS.md +++ b/crates/shift-ir/docs/DOCS.md @@ -18,7 +18,7 @@ Format-agnostic intermediate representation for font data, serving as the canoni **Architecture Invariant:** IR types must remain format-agnostic. No UFO paths, binary table offsets, or format-specific metadata belong here. Format-specific concerns live in `shift-backends`. WHY: the IR is the single source of truth shared by all readers, writers, and the editor core. -**Architecture Invariant:** `FontMetadata`, `FontMetrics`, and `DecomposedTransform` derive `ts_rs::TS` to auto-generate TypeScript types for the frontend. If you add fields to these types, run the TS export to keep types in sync. +**Architecture Invariant:** `shift-ir` must not know about TypeScript or bridge bindings. Frontend-facing DTOs live in `shift-wire`, and TypeScript bridge declarations are generated from `shift-bridge`. WHY: IR is the Rust domain model, not a cross-language API contract. ## Codemap @@ -66,9 +66,9 @@ src/ ## How it works -**Ownership hierarchy:** `Font` owns `HashMap`. Each `Glyph` owns `HashMap`. Each `GlyphLayer` owns contours (by `ContourId`), components (by `ComponentId`), and anchors (ordered `Vec`). This is a pure value tree with no reference counting or interior mutability. +**Ownership hierarchy:** `Font` uses copy-on-write storage around its document data and glyph map. Glyphs use copy-on-write layer storage. Each `GlyphLayer` owns contours (by `ContourId`), components (by `ComponentId`), and anchors (ordered `Vec`). This keeps save snapshots cheap while preserving normal value-style mutation APIs. -**Edit pattern:** The upstream `shift-core` crate uses `take_glyph()` / `put_glyph()` to temporarily extract a glyph from the font, mutate it through an `EditSession`, then return it. This avoids borrow conflicts since the font no longer holds the glyph during editing. +**Edit pattern:** The upstream `shift-edit` crate edits a `GlyphLayer` through `EditSession`. The bridge owns active edit lifecycle and commits the edited layer back to the glyph/font when the session ends. **Segment iteration:** `Contour::segments()` returns a `CurveSegmentIter` that classifies consecutive points by their on-curve/off-curve pattern: two on-curve points produce a `Line`, on-off-on produces a `Quad`, on-off-off-on produces a `Cubic`. For closed contours, the iterator wraps around from the last point back to the first. @@ -76,8 +76,6 @@ src/ **Variable fonts:** `Axis` defines a design space dimension. `Source` links a `Location` (axis coordinates) to a `LayerId`. `Axis::normalize()` / `denormalize()` map between user-space and normalized (-1..0..1) coordinates. -**TypeScript bridge:** `FontMetadata`, `FontMetrics`, and `DecomposedTransform` use `ts-rs` to export TypeScript type definitions to `packages/types/src/generated/`. - ## Workflow recipes ### Add a new field to a core type @@ -85,8 +83,8 @@ src/ 1. Add the field to the struct (e.g., in `glyph.rs`) 2. Update `Default` impl if applicable 3. Add getter/setter methods -4. If the type has `#[ts(export)]`, run `cargo test` to regenerate TS types -5. Update backend readers/writers in `shift-backends` to handle the new field +4. Update backend readers/writers in `shift-backends` to handle the new field +5. If the field crosses the bridge boundary, add or update the DTO in `shift-wire` ### Add a new entity type @@ -101,7 +99,7 @@ src/ 2. Call `boolean(BooleanOp::Union, &a, &b)` (or other op) 3. Result is `Contours` (a `Vec` wrapper) -- each contour has fresh IDs -### Perform a glyph edit (from shift-core) +### Perform a glyph edit (from shift-edit) 1. `font.take_glyph("A")` to extract 2. Mutate the glyph's layer data @@ -122,17 +120,15 @@ src/ # Run all shift-ir tests cargo test -p shift-ir -# Check TS type generation still works -cargo test -p shift-ir -- --ignored 2>/dev/null || cargo test -p shift-ir - # Verify downstream crates still compile -cargo check -p shift-core -p shift-backends +cargo check -p shift-edit -p shift-backends ``` ## Related -- `shift-core` -- editing logic (`EditSession`, constraint enforcement) that operates on IR types +- `shift-edit` -- editing logic (`EditSession`, constraint enforcement) that operates on IR types - `shift-backends` -- format readers/writers (UFO, Glyphs) that produce/consume `Font` -- `shift-node` -- NAPI bindings exposing IR types to the JavaScript/TypeScript frontend +- `shift-wire` -- bridge DTOs derived from IR/edit state +- `shift-bridge` -- NAPI bindings exposing bridge DTOs to JavaScript/TypeScript - `kurbo::BezPath` -- external type used for path geometry interop - `linesweeper` -- external crate powering `boolean()` operations diff --git a/crates/shift-ir/src/axis.rs b/crates/shift-ir/src/axis.rs index d7b5bdfc..4d37fc01 100644 --- a/crates/shift-ir/src/axis.rs +++ b/crates/shift-ir/src/axis.rs @@ -1,10 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use ts_rs::TS; -#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct Axis { tag: String, name: String, @@ -97,8 +95,7 @@ impl Axis { } } -#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../../packages/types/src/generated/")] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Location { values: HashMap, } diff --git a/crates/shift-ir/src/component.rs b/crates/shift-ir/src/component.rs index 6e479d12..5914f3c3 100644 --- a/crates/shift-ir/src/component.rs +++ b/crates/shift-ir/src/component.rs @@ -1,7 +1,6 @@ use crate::entity::ComponentId; use crate::GlyphName; use serde::{Deserialize, Serialize}; -use ts_rs::TS; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Component { @@ -30,9 +29,8 @@ pub struct Transform { /// Decomposed 2D transformation with explicit scale, rotation, skew, and translation. /// Composition order: translate to center → rotate → scale → skew → translate back -#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct DecomposedTransform { pub translate_x: f64, pub translate_y: f64, @@ -202,26 +200,41 @@ impl Transform { } impl Component { - pub fn new(base_glyph: GlyphName) -> Self { + pub fn new(base_glyph: impl Into) -> Self { Self { id: ComponentId::new(), - base_glyph, + base_glyph: base_glyph.into(), transform: DecomposedTransform::identity(), } } - pub fn with_transform(base_glyph: GlyphName, transform: DecomposedTransform) -> Self { + pub fn with_transform( + base_glyph: impl Into, + transform: DecomposedTransform, + ) -> Self { Self { id: ComponentId::new(), - base_glyph, + base_glyph: base_glyph.into(), transform, } } - pub fn with_matrix(base_glyph: GlyphName, matrix: &Transform) -> Self { + pub fn with_id( + id: ComponentId, + base_glyph: impl Into, + transform: DecomposedTransform, + ) -> Self { + Self { + id, + base_glyph: base_glyph.into(), + transform, + } + } + + pub fn with_matrix(base_glyph: impl Into, matrix: &Transform) -> Self { Self { id: ComponentId::new(), - base_glyph, + base_glyph: base_glyph.into(), transform: DecomposedTransform::from_matrix(matrix), } } @@ -259,7 +272,7 @@ mod tests { #[test] fn component_creation() { let c = Component::new("a".to_string()); - assert_eq!(c.base_glyph(), "a"); + assert_eq!(c.base_glyph().as_str(), "a"); assert!(c.transform().is_identity()); } diff --git a/crates/shift-ir/src/font.rs b/crates/shift-ir/src/font.rs index 85d95a2f..822997f0 100644 --- a/crates/shift-ir/src/font.rs +++ b/crates/shift-ir/src/font.rs @@ -11,11 +11,10 @@ use crate::source::Source; use crate::GlyphName; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use ts_rs::TS; +use std::sync::Arc; -#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct FontMetadata { pub family_name: Option, pub style_name: Option, @@ -79,6 +78,11 @@ impl FontMetadata { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Font { + inner: Arc, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct FontData { metadata: FontMetadata, metrics: FontMetrics, axes: Vec, @@ -86,7 +90,7 @@ pub struct Font { #[serde(default)] default_source_id: Option, layers: HashMap, - glyphs: HashMap, + glyphs: HashMap>, kerning: KerningData, features: FeatureData, guidelines: Vec, @@ -101,18 +105,20 @@ impl Default for Font { layers.insert(default_layer_id, Layer::default_layer()); Self { - metadata: FontMetadata::default(), - metrics: FontMetrics::default(), - axes: Vec::new(), - sources: Vec::new(), - default_source_id: None, - layers, - glyphs: HashMap::new(), - kerning: KerningData::new(), - features: FeatureData::new(), - guidelines: Vec::new(), - lib: LibData::new(), - default_layer_id, + inner: Arc::new(FontData { + metadata: FontMetadata::default(), + metrics: FontMetrics::default(), + axes: Vec::new(), + sources: Vec::new(), + default_source_id: None, + layers, + glyphs: HashMap::new(), + kerning: KerningData::new(), + features: FeatureData::new(), + guidelines: Vec::new(), + lib: LibData::new(), + default_layer_id, + }), } } } @@ -122,169 +128,247 @@ impl Font { Self::default() } + fn data(&self) -> &FontData { + &self.inner + } + + fn data_mut(&mut self) -> &mut FontData { + Arc::make_mut(&mut self.inner) + } + pub fn metadata(&self) -> &FontMetadata { - &self.metadata + &self.data().metadata } pub fn metadata_mut(&mut self) -> &mut FontMetadata { - &mut self.metadata + &mut self.data_mut().metadata } pub fn metrics(&self) -> &FontMetrics { - &self.metrics + &self.data().metrics } pub fn metrics_mut(&mut self) -> &mut FontMetrics { - &mut self.metrics + &mut self.data_mut().metrics } pub fn axes(&self) -> &[Axis] { - &self.axes + &self.data().axes } pub fn add_axis(&mut self, axis: Axis) { - self.axes.push(axis); + self.data_mut().axes.push(axis); } pub fn sources(&self) -> &[Source] { - &self.sources + &self.data().sources } pub fn add_source(&mut self, source: Source) -> SourceId { let source_id = source.id(); - if self.default_source_id.is_none() { - self.default_source_id = Some(source_id); + let data = self.data_mut(); + if data.default_source_id.is_none() { + data.default_source_id = Some(source_id); } - self.sources.push(source); + data.sources.push(source); source_id } pub fn default_source_id(&self) -> Option { - self.default_source_id + self.data().default_source_id } pub fn set_default_source_id(&mut self, source_id: SourceId) { - self.default_source_id = Some(source_id); + self.data_mut().default_source_id = Some(source_id); } pub fn default_source(&self) -> Option<&Source> { - let default_source_id = self.default_source_id?; - self.sources + let default_source_id = self.data().default_source_id?; + self.data() + .sources .iter() .find(|source| source.id() == default_source_id) } pub fn is_variable(&self) -> bool { - !self.axes.is_empty() + !self.data().axes.is_empty() } pub fn layers(&self) -> &HashMap { - &self.layers + &self.data().layers } pub fn layer(&self, id: LayerId) -> Option<&Layer> { - self.layers.get(&id) + self.data().layers.get(&id) } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.layers.get_mut(&id) + self.data_mut().layers.get_mut(&id) } pub fn default_layer_id(&self) -> LayerId { - self.default_layer_id + self.data().default_layer_id } pub fn default_layer(&self) -> Option<&Layer> { - self.layers.get(&self.default_layer_id) + self.data().layers.get(&self.data().default_layer_id) } pub fn add_layer(&mut self, layer: Layer) -> LayerId { let id = layer.id(); - self.layers.insert(id, layer); + self.data_mut().layers.insert(id, layer); id } - pub fn glyphs(&self) -> &HashMap { - &self.glyphs + pub fn glyphs(&self) -> &HashMap> { + &self.data().glyphs } pub fn glyph(&self, name: &str) -> Option<&Glyph> { - self.glyphs.get(name) + self.data().glyphs.get(name).map(Arc::as_ref) } pub fn glyph_mut(&mut self, name: &str) -> Option<&mut Glyph> { - self.glyphs.get_mut(name) + self.data_mut().glyphs.get_mut(name).map(Arc::make_mut) } pub fn glyph_by_unicode(&self, unicode: u32) -> Option<&Glyph> { - self.glyphs + self.data() + .glyphs .values() .find(|g| g.unicodes().contains(&unicode)) + .map(Arc::as_ref) } pub fn glyph_by_unicode_mut(&mut self, unicode: u32) -> Option<&mut Glyph> { - self.glyphs + self.data_mut() + .glyphs .values_mut() .find(|g| g.unicodes().contains(&unicode)) + .map(Arc::make_mut) } pub fn insert_glyph(&mut self, glyph: Glyph) { - self.glyphs.insert(glyph.name().to_string(), glyph); + self.data_mut() + .glyphs + .insert(glyph.glyph_name().clone(), Arc::new(glyph)); } pub fn remove_glyph(&mut self, name: &str) -> Option { - self.glyphs.remove(name) + self.data_mut() + .glyphs + .remove(name) + .map(Arc::unwrap_or_clone) } pub fn glyph_count(&self) -> usize { - self.glyphs.len() + self.data().glyphs.len() } pub fn take_glyph(&mut self, name: &str) -> Option { - self.glyphs.remove(name) + self.data_mut() + .glyphs + .remove(name) + .map(Arc::unwrap_or_clone) } pub fn put_glyph(&mut self, glyph: Glyph) { - self.glyphs.insert(glyph.name().to_string(), glyph); + self.data_mut() + .glyphs + .insert(glyph.glyph_name().clone(), Arc::new(glyph)); } pub fn kerning(&self) -> &KerningData { - &self.kerning + &self.data().kerning } pub fn kerning_mut(&mut self) -> &mut KerningData { - &mut self.kerning + &mut self.data_mut().kerning } pub fn features(&self) -> &FeatureData { - &self.features + &self.data().features } pub fn features_mut(&mut self) -> &mut FeatureData { - &mut self.features + &mut self.data_mut().features } pub fn guidelines(&self) -> &[Guideline] { - &self.guidelines + &self.data().guidelines } pub fn add_guideline(&mut self, guideline: Guideline) { - self.guidelines.push(guideline); + self.data_mut().guidelines.push(guideline); } pub fn lib(&self) -> &LibData { - &self.lib + &self.data().lib } pub fn lib_mut(&mut self) -> &mut LibData { - &mut self.lib + &mut self.data_mut().lib } } #[cfg(test)] mod tests { use super::*; - use crate::glyph::GlyphLayer; + use crate::{Contour, GlyphLayer, PointType}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + #[derive(Clone, Copy)] + struct PerfFontMark { + label: &'static str, + glyphs: usize, + contours_per_glyph: usize, + points_per_contour: usize, + } + + impl PerfFontMark { + fn total_points(self) -> usize { + self.glyphs * self.contours_per_glyph * self.points_per_contour + } + } + + fn synthetic_point_heavy_font(mark: PerfFontMark) -> Font { + let mut font = Font::new(); + let default_layer_id = font.default_layer_id(); + + for glyph_index in 0..mark.glyphs { + let mut glyph = Glyph::with_unicode(format!("g{glyph_index:05}"), glyph_index as u32); + let mut layer = GlyphLayer::with_width(500.0 + glyph_index as f64); + + for contour_index in 0..mark.contours_per_glyph { + let mut contour = Contour::new(); + for point_index in 0..mark.points_per_contour { + contour.add_point( + point_index as f64, + (glyph_index + contour_index + point_index) as f64, + PointType::OnCurve, + false, + ); + } + layer.add_contour(contour); + } + + glyph.set_layer(default_layer_id, layer); + font.insert_glyph(glyph); + } + + font + } + + fn print_perf_mark(operation: &str, mark: PerfFontMark, elapsed: Duration) { + eprintln!( + "perf_mark {operation} [{}]: {} glyphs / {} points in {:?}", + mark.label, + mark.glyphs, + mark.total_points(), + elapsed + ); + } #[test] fn font_creation() { @@ -319,4 +403,142 @@ mod tests { font.put_glyph(taken.unwrap()); assert_eq!(font.glyph_count(), 1); } + + #[test] + fn cloned_font_shares_storage_until_mutated() { + let mut font = Font::new(); + let snapshot = font.clone(); + + assert!(Arc::ptr_eq(&font.inner, &snapshot.inner)); + + font.metadata_mut().family_name = Some("Edited".to_string()); + + assert!(!Arc::ptr_eq(&font.inner, &snapshot.inner)); + assert_eq!(font.metadata().family_name.as_deref(), Some("Edited")); + assert_eq!( + snapshot.metadata().family_name.as_deref(), + Some("Untitled Font") + ); + } + + #[test] + fn mutating_one_glyph_after_snapshot_keeps_other_glyphs_shared() { + let mut font = Font::new(); + font.insert_glyph(Glyph::with_unicode("A".to_string(), 65)); + font.insert_glyph(Glyph::with_unicode("B".to_string(), 66)); + let snapshot = font.clone(); + + font.glyph_mut("A") + .unwrap() + .set_unicodes(vec![0x41, 0x00C1]); + + assert_eq!(font.glyph("A").unwrap().unicodes(), &[0x41, 0x00C1]); + assert_eq!(snapshot.glyph("A").unwrap().unicodes(), &[0x41]); + assert!(!Arc::ptr_eq( + font.inner.glyphs.get("A").unwrap(), + snapshot.inner.glyphs.get("A").unwrap() + )); + assert!(Arc::ptr_eq( + font.inner.glyphs.get("B").unwrap(), + snapshot.inner.glyphs.get("B").unwrap() + )); + } + + #[test] + fn perf_mark_large_font_clone_is_cow_snapshot() { + let marks = [ + PerfFontMark { + label: "small-latin", + glyphs: 250, + contours_per_glyph: 2, + points_per_contour: 12, + }, + PerfFontMark { + label: "large-latin", + glyphs: 2_000, + contours_per_glyph: 4, + points_per_contour: 16, + }, + PerfFontMark { + label: "cjk-scale", + glyphs: 10_000, + contours_per_glyph: 2, + points_per_contour: 8, + }, + ]; + + for mark in marks { + let font = synthetic_point_heavy_font(mark); + let start = Instant::now(); + let snapshots: Vec<_> = (0..128).map(|_| font.clone()).collect(); + let elapsed = start.elapsed(); + + assert_eq!(font.glyph_count(), mark.glyphs); + for snapshot in &snapshots { + assert!(Arc::ptr_eq(&font.inner, &snapshot.inner)); + assert_eq!(snapshot.glyph_count(), font.glyph_count()); + } + + print_perf_mark("font.clone snapshots x128", mark, elapsed); + assert!( + elapsed < Duration::from_secs(1), + "COW snapshot creation should stay comfortably sub-second for {}; got {elapsed:?}", + mark.label + ); + } + } + + #[test] + fn perf_mark_large_font_mutating_one_glyph_preserves_unedited_glyph_sharing() { + let mark = PerfFontMark { + label: "cjk-scale", + glyphs: 10_000, + contours_per_glyph: 2, + points_per_contour: 8, + }; + let mut font = synthetic_point_heavy_font(mark); + let snapshot = font.clone(); + let default_layer_id = font.default_layer_id(); + let start = Instant::now(); + + font.glyph_mut("g00000") + .expect("target glyph should exist") + .layer_mut(default_layer_id) + .expect("target layer should exist") + .set_width(777.0); + + let elapsed = start.elapsed(); + + assert_eq!( + font.glyph("g00000") + .unwrap() + .layer(default_layer_id) + .unwrap() + .width(), + 777.0 + ); + assert_ne!( + snapshot + .glyph("g00000") + .unwrap() + .layer(snapshot.default_layer_id()) + .unwrap() + .width(), + 777.0 + ); + assert!(!Arc::ptr_eq( + font.inner.glyphs.get("g00000").unwrap(), + snapshot.inner.glyphs.get("g00000").unwrap() + )); + assert!(Arc::ptr_eq( + font.inner.glyphs.get("g00001").unwrap(), + snapshot.inner.glyphs.get("g00001").unwrap() + )); + + print_perf_mark("single glyph mutation after snapshot", mark, elapsed); + assert!( + elapsed < Duration::from_secs(1), + "single-glyph COW mutation should stay comfortably sub-second; got {elapsed:?}" + ); + } } diff --git a/crates/shift-ir/src/glyph.rs b/crates/shift-ir/src/glyph.rs index 6363c0e4..82fe86ad 100644 --- a/crates/shift-ir/src/glyph.rs +++ b/crates/shift-ir/src/glyph.rs @@ -8,13 +8,14 @@ use crate::GlyphName; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::Arc; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Glyph { id: GlyphId, name: GlyphName, unicodes: Vec, - layers: HashMap, + layers: HashMap>, lib: LibData, } @@ -65,6 +66,10 @@ impl GlyphLayer { self.contours.values() } + pub fn contours_iter_mut(&mut self) -> impl Iterator { + self.contours.values_mut() + } + pub fn contour(&self, id: ContourId) -> Option<&Contour> { self.contours.get(&id) } @@ -109,6 +114,10 @@ impl GlyphLayer { self.components.remove(&id) } + pub fn clear_components(&mut self) { + self.components.clear(); + } + pub fn anchors(&self) -> &[Anchor] { &self.anchors } @@ -185,20 +194,20 @@ impl GlyphLayer { } impl Glyph { - pub fn new(name: GlyphName) -> Self { + pub fn new(name: impl Into) -> Self { Self { id: GlyphId::new(), - name, + name: name.into(), unicodes: Vec::new(), layers: HashMap::new(), lib: LibData::new(), } } - pub fn with_unicode(name: GlyphName, unicode: u32) -> Self { + pub fn with_unicode(name: impl Into, unicode: u32) -> Self { Self { id: GlyphId::new(), - name, + name: name.into(), unicodes: vec![unicode], layers: HashMap::new(), lib: LibData::new(), @@ -210,11 +219,15 @@ impl Glyph { } pub fn name(&self) -> &str { + self.name.as_str() + } + + pub fn glyph_name(&self) -> &GlyphName { &self.name } - pub fn set_name(&mut self, name: GlyphName) { - self.name = name; + pub fn set_name(&mut self, name: impl Into) { + self.name = name.into(); } pub fn unicodes(&self) -> &[u32] { @@ -239,28 +252,28 @@ impl Glyph { self.unicodes = unicodes; } - pub fn layers(&self) -> &HashMap { + pub fn layers(&self) -> &HashMap> { &self.layers } pub fn layer(&self, id: LayerId) -> Option<&GlyphLayer> { - self.layers.get(&id) + self.layers.get(&id).map(Arc::as_ref) } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut GlyphLayer> { - self.layers.get_mut(&id) + self.layers.get_mut(&id).map(Arc::make_mut) } pub fn get_or_create_layer(&mut self, id: LayerId) -> &mut GlyphLayer { - self.layers.entry(id).or_default() + Arc::make_mut(self.layers.entry(id).or_default()) } pub fn set_layer(&mut self, id: LayerId, layer: GlyphLayer) { - self.layers.insert(id, layer); + self.layers.insert(id, Arc::new(layer)); } pub fn remove_layer(&mut self, id: LayerId) -> Option { - self.layers.remove(&id) + self.layers.remove(&id).map(Arc::unwrap_or_clone) } pub fn lib(&self) -> &LibData { @@ -276,6 +289,7 @@ impl Glyph { mod tests { use super::*; use crate::Anchor; + use std::sync::Arc; #[test] fn glyph_creation() { @@ -295,6 +309,32 @@ mod tests { assert_eq!(g.layer(layer_id).unwrap().width(), 600.0); } + #[test] + fn cloned_glyph_shares_layers_until_one_layer_is_mutated() { + let mut glyph = Glyph::new("A".to_string()); + let first_layer_id = LayerId::new(); + let second_layer_id = LayerId::new(); + glyph.set_layer(first_layer_id, GlyphLayer::with_width(500.0)); + glyph.set_layer(second_layer_id, GlyphLayer::with_width(600.0)); + let snapshot = glyph.clone(); + + glyph + .layer_mut(first_layer_id) + .expect("first layer should exist") + .set_width(700.0); + + assert_eq!(glyph.layer(first_layer_id).unwrap().width(), 700.0); + assert_eq!(snapshot.layer(first_layer_id).unwrap().width(), 500.0); + assert!(!Arc::ptr_eq( + glyph.layers.get(&first_layer_id).unwrap(), + snapshot.layers.get(&first_layer_id).unwrap() + )); + assert!(Arc::ptr_eq( + glyph.layers.get(&second_layer_id).unwrap(), + snapshot.layers.get(&second_layer_id).unwrap() + )); + } + #[test] fn glyph_layer_contours() { let mut layer = GlyphLayer::with_width(500.0); diff --git a/crates/shift-ir/src/glyph_name.rs b/crates/shift-ir/src/glyph_name.rs new file mode 100644 index 00000000..c50667e9 --- /dev/null +++ b/crates/shift-ir/src/glyph_name.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; +use std::borrow::Borrow; +use std::fmt; +use std::ops::Deref; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GlyphName(String); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GlyphNameError { + Empty, +} + +impl GlyphName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(GlyphNameError::Empty); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl From for GlyphName { + fn from(value: String) -> Self { + Self::new(value).expect("glyph name must not be empty") + } +} + +impl From<&str> for GlyphName { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for String { + fn from(value: GlyphName) -> Self { + value.0 + } +} + +impl AsRef for GlyphName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Borrow for GlyphName { + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl Deref for GlyphName { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for GlyphName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl fmt::Display for GlyphNameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("glyph name must not be empty"), + } + } +} + +impl std::error::Error for GlyphNameError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_names() { + assert_eq!(GlyphName::new(""), Err(GlyphNameError::Empty)); + } + + #[test] + fn borrows_as_str_for_map_lookup() { + let mut names = std::collections::HashMap::new(); + names.insert(GlyphName::from("A"), 1); + + assert_eq!(names.get("A"), Some(&1)); + } +} diff --git a/crates/shift-ir/src/kerning.rs b/crates/shift-ir/src/kerning.rs index 3b29a717..328ffd3f 100644 --- a/crates/shift-ir/src/kerning.rs +++ b/crates/shift-ir/src/kerning.rs @@ -24,10 +24,14 @@ impl KerningPair { } } - pub fn glyph_pair(first: GlyphName, second: GlyphName, value: f64) -> Self { + pub fn glyph_pair( + first: impl Into, + second: impl Into, + value: f64, + ) -> Self { Self { - first: KerningSide::Glyph(first), - second: KerningSide::Glyph(second), + first: KerningSide::Glyph(first.into()), + second: KerningSide::Glyph(second.into()), value, } } @@ -53,23 +57,23 @@ impl KerningData { self.pairs.push(pair); } - pub fn get_kerning(&self, first: &GlyphName, second: &GlyphName) -> Option { + pub fn get_kerning(&self, first: &str, second: &str) -> Option { for pair in &self.pairs { let first_matches = match &pair.first { - KerningSide::Glyph(g) => g == first, + KerningSide::Glyph(g) => g.as_str() == first, KerningSide::Group(group) => self .groups1 .get(group) - .map(|members| members.contains(first)) + .map(|members| members.iter().any(|member| member.as_str() == first)) .unwrap_or(false), }; let second_matches = match &pair.second { - KerningSide::Glyph(g) => g == second, + KerningSide::Glyph(g) => g.as_str() == second, KerningSide::Group(group) => self .groups2 .get(group) - .map(|members| members.contains(second)) + .map(|members| members.iter().any(|member| member.as_str() == second)) .unwrap_or(false), }; @@ -114,14 +118,8 @@ mod tests { -50.0, )); - assert_eq!( - kerning.get_kerning(&"A".to_string(), &"V".to_string()), - Some(-50.0) - ); - assert_eq!( - kerning.get_kerning(&"A".to_string(), &"B".to_string()), - None - ); + assert_eq!(kerning.get_kerning("A", "V"), Some(-50.0)); + assert_eq!(kerning.get_kerning("A", "B"), None); } #[test] @@ -129,25 +127,16 @@ mod tests { let mut kerning = KerningData::new(); kerning.set_group1( "public.kern1.A".to_string(), - vec!["A".to_string(), "Aacute".to_string()], - ); - kerning.set_group2( - "public.kern2.V".to_string(), - vec!["V".to_string(), "W".to_string()], + vec!["A".into(), "Aacute".into()], ); + kerning.set_group2("public.kern2.V".to_string(), vec!["V".into(), "W".into()]); kerning.add_pair(KerningPair::new( KerningSide::Group("public.kern1.A".to_string()), KerningSide::Group("public.kern2.V".to_string()), -40.0, )); - assert_eq!( - kerning.get_kerning(&"A".to_string(), &"V".to_string()), - Some(-40.0) - ); - assert_eq!( - kerning.get_kerning(&"Aacute".to_string(), &"W".to_string()), - Some(-40.0) - ); + assert_eq!(kerning.get_kerning("A", "V"), Some(-40.0)); + assert_eq!(kerning.get_kerning("Aacute", "W"), Some(-40.0)); } } diff --git a/crates/shift-ir/src/lib.rs b/crates/shift-ir/src/lib.rs index b22c658f..65ef1ea3 100644 --- a/crates/shift-ir/src/lib.rs +++ b/crates/shift-ir/src/lib.rs @@ -7,6 +7,7 @@ mod entity; mod features; mod font; mod glyph; +mod glyph_name; mod guideline; mod kerning; mod layer; @@ -28,6 +29,7 @@ pub use entity::{ pub use features::FeatureData; pub use font::{Font, FontMetadata}; pub use glyph::{Glyph, GlyphLayer}; +pub use glyph_name::{GlyphName, GlyphNameError}; pub use guideline::{Guideline, GuidelineOrientation}; pub use kerning::{KerningData, KerningPair, KerningSide}; pub use layer::Layer; @@ -36,5 +38,3 @@ pub use metrics::FontMetrics; pub use point::{Point, PointType}; pub use segment::{CurveSegment, CurveSegmentIter}; pub use source::Source; - -pub type GlyphName = String; diff --git a/crates/shift-ir/src/metrics.rs b/crates/shift-ir/src/metrics.rs index e52505a6..40fa11d5 100644 --- a/crates/shift-ir/src/metrics.rs +++ b/crates/shift-ir/src/metrics.rs @@ -1,9 +1,7 @@ use serde::{Deserialize, Serialize}; -use ts_rs::TS; -#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct FontMetrics { pub units_per_em: f64, pub ascender: f64, diff --git a/crates/shift-ir/src/source.rs b/crates/shift-ir/src/source.rs index aa593e29..0c44f278 100644 --- a/crates/shift-ir/src/source.rs +++ b/crates/shift-ir/src/source.rs @@ -1,17 +1,13 @@ use crate::axis::Location; use crate::entity::{LayerId, SourceId}; use serde::{Deserialize, Serialize}; -use ts_rs::TS; -#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct Source { - #[ts(type = "string")] id: SourceId, name: String, location: Location, - #[ts(type = "string")] layer_id: LayerId, filename: Option, } diff --git a/crates/shift-node/Cargo.toml b/crates/shift-node/Cargo.toml deleted file mode 100644 index eed9d45c..00000000 --- a/crates/shift-node/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -edition = "2021" -name = "shift-node" -version = "0.0.1" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -shift-core = { workspace = true } -# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "3.8", default-features = false, features = ["napi4"] } -napi-derive = "3.5" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0" - -[build-dependencies] -napi-build = "2.1" diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs deleted file mode 100644 index e6d08828..00000000 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ /dev/null @@ -1,662 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createRequire } from "module"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; -import { existsSync, mkdtempSync, rmSync } from "fs"; -import { tmpdir } from "os"; - -const require = createRequire(import.meta.url); -const { FontEngine } = require("../index.js"); - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const FIXTURES_PATH = join(__dirname, "..", "..", "..", "fixtures"); -const MUTATORSANS_UFO = join(FIXTURES_PATH, "fonts/mutatorsans/MutatorSansLightCondensed.ufo"); -const MUTATORSANS_TTF = join(FIXTURES_PATH, "fonts/mutatorsans/MutatorSans.ttf"); - -function startEditSessionByUnicode(engine, unicode) { - const glyphName = - engine.getGlyphNameForUnicode(unicode) ?? - `uni${unicode.toString(16).toUpperCase().padStart(4, "0")}`; - engine.startEditSession({ glyphName, unicode }); -} - -describe("FontEngine Integration - UFO Loading", () => { - it("loads MutatorSans UFO successfully", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - expect(() => engine.loadFont(MUTATORSANS_UFO)).not.toThrow(); - expect(engine.getGlyphCount()).toBe(48); - }); - - it("returns correct metadata from MutatorSans UFO", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - const metadata = JSON.parse(engine.getMetadata()); - expect(metadata.familyName).toBe("MutatorMathTest"); - expect(metadata.styleName).toBe("LightCondensed"); - }); - - it("returns correct metrics from MutatorSans UFO", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - const metrics = JSON.parse(engine.getMetrics()); - expect(metrics.unitsPerEm).toBe(1000); - expect(metrics.ascender).toBe(700); - expect(metrics.descender).toBe(-200); - expect(metrics.capHeight).toBe(700); - expect(metrics.xHeight).toBe(500); - }); - - it("returns glyph unicodes from loaded font", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - const unicodes = engine.getGlyphUnicodes(); - expect(Array.isArray(unicodes)).toBe(true); - expect(unicodes.length).toBeGreaterThan(0); - expect(unicodes).toContain(65); - expect(unicodes).toContain(66); - }); - - it("returns SVG path for glyph by unicode", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - const pathA = engine.getGlyphSvgPath(65); - expect(pathA).toBeTruthy(); - expect(typeof pathA).toBe("string"); - expect(pathA.length).toBeGreaterThan(0); - expect(pathA).toMatch(/^M\s/); - - const pathMissing = engine.getGlyphSvgPath(0xffff); - expect(pathMissing).toBeNull(); - }); -}); - -describe("FontEngine Integration - Edit Session", () => { - it("starts edit session and gets snapshot with real contour data", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 65); - expect(engine.hasEditSession()).toBe(true); - - const snapshot = JSON.parse(engine.getSnapshotData()); - expect(snapshot).toBeTruthy(); - expect(snapshot.name).toBe("A"); - expect(snapshot.unicode).toBe(65); - expect(snapshot.contours.length).toBeGreaterThan(0); - - const contour = snapshot.contours[0]; - expect(contour.points.length).toBeGreaterThan(0); - - const point = contour.points[0]; - expect(typeof point.x).toBe("number"); - expect(typeof point.y).toBe("number"); - expect(["onCurve", "offCurve"]).toContain(point.pointType); - - engine.endEditSession(); - }); - - it("getSnapshotData returns native snapshot object", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 65); - const snapshot = JSON.parse(engine.getSnapshotData()); - - expect(snapshot.name).toBe("A"); - expect(snapshot.unicode).toBe(65); - expect(snapshot.contours.length).toBeGreaterThan(0); - - engine.endEditSession(); - }); -}); - -describe("FontEngine Integration - Round Trip", () => { - let tempDir; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "shift-test-")); - }); - - afterEach(() => { - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("saves and reloads UFO with same glyph count", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - const originalCount = engine.getGlyphCount(); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - - expect(engine2.getGlyphCount()).toBe(originalCount); - }); - - it("preserves metrics after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - const originalMetrics = JSON.parse(engine.getMetrics()); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - const reloadedMetrics = JSON.parse(engine2.getMetrics()); - - expect(reloadedMetrics.unitsPerEm).toBe(originalMetrics.unitsPerEm); - expect(reloadedMetrics.ascender).toBe(originalMetrics.ascender); - expect(reloadedMetrics.descender).toBe(originalMetrics.descender); - }); - - it("preserves glyph contour data after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 65); - const originalSnapshot = JSON.parse(engine.getSnapshotData()); - engine.endEditSession(); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - - startEditSessionByUnicode(engine2, 65); - const reloadedSnapshot = JSON.parse(engine2.getSnapshotData()); - engine2.endEditSession(); - - expect(reloadedSnapshot.contours.length).toBe(originalSnapshot.contours.length); - - for (let i = 0; i < originalSnapshot.contours.length; i++) { - const origContour = originalSnapshot.contours[i]; - const reloadContour = reloadedSnapshot.contours[i]; - - expect(reloadContour.points.length).toBe(origContour.points.length); - expect(reloadContour.closed).toBe(origContour.closed); - } - }); -}); - -describe("FontEngine Integration - TTF Loading", () => { - it("loads MutatorSans TTF successfully", () => { - if (!existsSync(MUTATORSANS_TTF)) { - return; - } - - const engine = new FontEngine(); - expect(() => engine.loadFont(MUTATORSANS_TTF)).not.toThrow(); - expect(engine.getGlyphCount()).toBeGreaterThan(0); - }); - - it("can start edit session on TTF glyph", () => { - if (!existsSync(MUTATORSANS_TTF)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_TTF); - - startEditSessionByUnicode(engine, 65); - expect(engine.hasEditSession()).toBe(true); - - const snapshot = JSON.parse(engine.getSnapshotData()); - expect(snapshot.unicode).toBe(65); - - engine.endEditSession(); - }); - - it("TTF glyph A has contour data", () => { - if (!existsSync(MUTATORSANS_TTF)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_TTF); - - startEditSessionByUnicode(engine, 65); - const snapshot = JSON.parse(engine.getSnapshotData()); - - expect(snapshot.contours.length).toBeGreaterThan(0); - - const firstContour = snapshot.contours[0]; - expect(firstContour.points.length).toBeGreaterThan(0); - - const hasOnCurve = firstContour.points.some((p) => p.pointType === "onCurve"); - expect(hasOnCurve).toBe(true); - - engine.endEditSession(); - }); -}); - -describe("FontEngine Integration - Composite Glyphs", () => { - it("loads composite glyph Aacute (unicode 193)", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 193); - expect(engine.hasEditSession()).toBe(true); - - const snapshot = JSON.parse(engine.getSnapshotData()); - expect(snapshot.name).toBe("Aacute"); - expect(snapshot.unicode).toBe(193); - - engine.endEditSession(); - }); -}); - -describe("FontEngine Integration - Point Types", () => { - it("preserves point types after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 79); - const originalSnapshot = JSON.parse(engine.getSnapshotData()); - engine.endEditSession(); - - const originalPointTypes = []; - for (const contour of originalSnapshot.contours) { - for (const point of contour.points) { - originalPointTypes.push(point.pointType); - } - } - - const hasOnCurve = originalPointTypes.includes("onCurve"); - const hasOffCurve = originalPointTypes.includes("offCurve"); - - expect(hasOnCurve).toBe(true); - expect(hasOffCurve).toBe(true); - }); - - it("preserves smooth flags after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 79); - const snapshot = JSON.parse(engine.getSnapshotData()); - engine.endEditSession(); - - const smoothFlags = []; - for (const contour of snapshot.contours) { - for (const point of contour.points) { - smoothFlags.push(point.smooth); - } - } - - expect(smoothFlags.length).toBeGreaterThan(0); - }); - - it("preserves closed contour state after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 65); - const originalSnapshot = JSON.parse(engine.getSnapshotData()); - engine.endEditSession(); - - for (const contour of originalSnapshot.contours) { - expect(contour.closed).toBe(true); - } - }); -}); - -describe("FontEngine Integration - Extended Round Trip", () => { - let tempDir; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "shift-test-")); - }); - - afterEach(() => { - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("preserves metadata after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - const originalMetadata = JSON.parse(engine.getMetadata()); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - const reloadedMetadata = JSON.parse(engine2.getMetadata()); - - expect(reloadedMetadata.familyName).toBe(originalMetadata.familyName); - expect(reloadedMetadata.styleName).toBe(originalMetadata.styleName); - }); - - it("preserves point coordinates with precision after round-trip", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 65); - const originalSnapshot = JSON.parse(engine.getSnapshotData()); - const originalPoints = []; - for (const contour of originalSnapshot.contours) { - for (const point of contour.points) { - originalPoints.push({ x: point.x, y: point.y }); - } - } - engine.endEditSession(); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - - startEditSessionByUnicode(engine2, 65); - const reloadedSnapshot = JSON.parse(engine2.getSnapshotData()); - const reloadedPoints = []; - for (const contour of reloadedSnapshot.contours) { - for (const point of contour.points) { - reloadedPoints.push({ x: point.x, y: point.y }); - } - } - engine2.endEditSession(); - - expect(reloadedPoints.length).toBe(originalPoints.length); - - const sortByCoords = (a, b) => { - if (Math.abs(a.x - b.x) > 0.001) return a.x - b.x; - return a.y - b.y; - }; - - const sortedOriginal = [...originalPoints].sort(sortByCoords); - const sortedReloaded = [...reloadedPoints].sort(sortByCoords); - - for (let i = 0; i < sortedOriginal.length; i++) { - expect(Math.abs(sortedOriginal[i].x - sortedReloaded[i].x)).toBeLessThan(0.001); - expect(Math.abs(sortedOriginal[i].y - sortedReloaded[i].y)).toBeLessThan(0.001); - } - }); - - it("preserves point types after save and reload", () => { - if (!existsSync(MUTATORSANS_UFO)) { - return; - } - - const sortContoursByFirstPoint = (contours) => { - return [...contours].sort((a, b) => { - const ax = a.points[0]?.x ?? 0; - const ay = a.points[0]?.y ?? 0; - const bx = b.points[0]?.x ?? 0; - const by = b.points[0]?.y ?? 0; - if (Math.abs(ax - bx) > 0.001) return ax - bx; - return ay - by; - }); - }; - - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - - startEditSessionByUnicode(engine, 79); - const originalSnapshot = JSON.parse(engine.getSnapshotData()); - const originalTypes = []; - for (const contour of sortContoursByFirstPoint(originalSnapshot.contours)) { - for (const point of contour.points) { - originalTypes.push(point.pointType); - } - } - engine.endEditSession(); - - const outputPath = join(tempDir, "output.ufo"); - engine.saveFont(outputPath); - - const engine2 = new FontEngine(); - engine2.loadFont(outputPath); - - startEditSessionByUnicode(engine2, 79); - const reloadedSnapshot = JSON.parse(engine2.getSnapshotData()); - const reloadedTypes = []; - for (const contour of sortContoursByFirstPoint(reloadedSnapshot.contours)) { - for (const point of contour.points) { - reloadedTypes.push(point.pointType); - } - } - engine2.endEditSession(); - - expect(reloadedTypes).toEqual(originalTypes); - }); -}); - -// --- Variable font (.glyphs with multiple masters) tests --- - -const MUTATORSANS_VARIABLE = join(FIXTURES_PATH, "fonts/MutatorSansVariable.glyphs"); - -describe("FontEngine Integration - Variable Font (.glyphs)", () => { - it("detects variable font", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_VARIABLE); - expect(engine.isVariable()).toBe(true); - }); - - it("returns axes", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_VARIABLE); - const axes = JSON.parse(engine.getAxes()); - expect(axes).toHaveLength(1); - expect(axes[0].tag).toBe("wght"); - expect(axes[0].name).toBe("Weight"); - expect(axes[0].minimum).toBe(100); - expect(axes[0].maximum).toBe(900); - expect(axes[0].default).toBe(100); - }); - - it("returns sources", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_VARIABLE); - const sources = JSON.parse(engine.getSources()); - expect(sources).toHaveLength(2); - expect(sources[0].location.values.wght).toBe(100); - expect(sources[1].location.values.wght).toBe(900); - }); - - it("returns master snapshots for glyph A", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_VARIABLE); - const json = engine.getGlyphMasterSnapshots("A"); - expect(json).not.toBeNull(); - const masters = JSON.parse(json); - expect(masters).toHaveLength(2); - - // Each master snapshot carries source metadata plus interpolation geometry. - for (const m of masters) { - expect(m).toHaveProperty("sourceId"); - expect(m).toHaveProperty("sourceName"); - expect(m).toHaveProperty("location"); - expect(m).toHaveProperty("isDefaultSource"); - expect(m).toHaveProperty("geometry"); - expect(m).not.toHaveProperty("snapshot"); - expect(m.geometry.contours).toHaveLength(2); - expect(m.geometry.xAdvance).toBeGreaterThan(0); - } - - // Both masters should have matching total point counts - const lightTotal = masters[0].geometry.contours.reduce((s, c) => s + c.points.length, 0); - const boldTotal = masters[1].geometry.contours.reduce((s, c) => s + c.points.length, 0); - expect(lightTotal).toBe(boldTotal); - }); - - it("non-variable font returns isVariable false", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_UFO); - expect(engine.isVariable()).toBe(false); - }); - - it("returns null for non-existent glyph master snapshots", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_VARIABLE); - const json = engine.getGlyphMasterSnapshots("nonexistent"); - expect(json).toBeNull(); - }); -}); - -// --- Designspace (.designspace) tests --- - -const MUTATORSANS_DESIGNSPACE = join( - FIXTURES_PATH, - "fonts/mutatorsans-variable/MutatorSans.designspace", -); - -describe("FontEngine Integration - Designspace", () => { - it("loads designspace and detects variable font", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - expect(engine.isVariable()).toBe(true); - expect(engine.getGlyphCount()).toBeGreaterThan(10); - }); - - it("returns axes from designspace", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - const axes = JSON.parse(engine.getAxes()); - expect(axes).toHaveLength(2); - expect(axes[0].tag).toBe("wdth"); - expect(axes[0].minimum).toBe(0); - expect(axes[0].maximum).toBe(1000); - expect(axes[1].tag).toBe("wght"); - }); - - it("returns sources from designspace", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - const sources = JSON.parse(engine.getSources()); - // 4 main masters + 3 support layers - expect(sources).toHaveLength(7); - expect(sources[0].location.values.wdth).toBe(0); - expect(sources[0].location.values.wght).toBe(0); - }); - - it("returns master snapshots for glyph A", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - const json = engine.getGlyphMasterSnapshots("A"); - expect(json).not.toBeNull(); - const masters = JSON.parse(json); - expect(masters.length).toBeGreaterThanOrEqual(4); - expect(masters.filter((m) => m.isDefaultSource)).toHaveLength(1); - for (const m of masters) { - expect(m).toHaveProperty("geometry"); - expect(m).not.toHaveProperty("snapshot"); - expect(m.geometry.contours.length).toBeGreaterThan(0); - expect(m.geometry.xAdvance).toBeGreaterThan(0); - } - }); - - it("excludes empty contours from master snapshots", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - const json = engine.getGlyphMasterSnapshots("A"); - const masters = JSON.parse(json); - for (const m of masters) { - for (const contour of m.geometry.contours) { - expect(contour.points.length).toBeGreaterThan(0); - } - } - }); - - it("returns consistent contour order across masters", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - const json = engine.getGlyphMasterSnapshots("A"); - const masters = JSON.parse(json); - - // All masters should have the same contour signature (point counts per contour) - const sigs = masters.map((m) => m.geometry.contours.map((c) => c.points.length).join(",")); - const uniqueSigs = new Set(sigs); - expect(uniqueSigs.size).toBe(1); - }); - - it("returns master snapshots for the currently editing glyph", () => { - const engine = new FontEngine(); - engine.loadFont(MUTATORSANS_DESIGNSPACE); - engine.startEditSession({ glyphName: "A", unicode: 65 }); - - // Glyph is taken from font during session, but snapshots should still work - const json = engine.getGlyphMasterSnapshots("A"); - expect(json).not.toBeNull(); - const masters = JSON.parse(json); - expect(masters.length).toBeGreaterThanOrEqual(4); - - engine.endEditSession(); - }); -}); diff --git a/crates/shift-node/__test__/index.spec.mjs b/crates/shift-node/__test__/index.spec.mjs deleted file mode 100644 index 376e79aa..00000000 --- a/crates/shift-node/__test__/index.spec.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createRequire } from "module"; - -// Need to use createRequire for CommonJS modules in ESM context -const require = createRequire(import.meta.url); -const { FontEngine } = require("../index.js"); - -describe("FontEngine", () => { - it("FontEngine creation", () => { - const engine = new FontEngine(); - expect(engine).toBeTruthy(); - expect(typeof engine.getMetadata).toBe("function"); - expect(typeof engine.getMetrics).toBe("function"); - expect(typeof engine.getGlyphCount).toBe("function"); - }); - - it("FontEngine default values", () => { - const engine = new FontEngine(); - const metadata = JSON.parse(engine.getMetadata()); - const metrics = JSON.parse(engine.getMetrics()); - - expect(metadata.familyName).toBe("Untitled Font"); - expect(metadata.styleName).toBe("Regular"); - expect(metadata.versionMajor).toBe(1); - expect(metadata.versionMinor).toBe(0); - expect(metrics.unitsPerEm).toBe(1000); - expect(metrics.ascender).toBe(800); - expect(metrics.descender).toBe(-200); - expect(metrics.capHeight).toBe(700); - expect(metrics.xHeight).toBe(500); - expect(engine.getGlyphCount()).toBe(0); - }); -}); diff --git a/crates/shift-node/build.rs b/crates/shift-node/build.rs deleted file mode 100644 index 1f866b6a..00000000 --- a/crates/shift-node/build.rs +++ /dev/null @@ -1,5 +0,0 @@ -extern crate napi_build; - -fn main() { - napi_build::setup(); -} diff --git a/crates/shift-node/docs/DOCS.md b/crates/shift-node/docs/DOCS.md deleted file mode 100644 index 4d1afe53..00000000 --- a/crates/shift-node/docs/DOCS.md +++ /dev/null @@ -1,117 +0,0 @@ -# shift-node - -NAPI bindings that expose `shift-core` to the Node.js/Electron renderer process as a single `FontEngine` class. - -## Architecture Invariants - -**Architecture Invariant:** Only one `EditSession` may be active at a time. Starting a second session returns an error. **WHY:** The session borrows the glyph out of the `Font` (via `take_glyph`), so concurrent sessions would leave the font in an inconsistent state. - -**Architecture Invariant:** All mutation methods return a JSON-serialized `CommandResult`, never raw values. **WHY:** The JS side parses a uniform `{success, snapshot, error, affectedPointIds, canUndo, canRedo}` shape, so the bridge never needs per-method return-type handling. - -**Architecture Invariant: CRITICAL:** Entity IDs are stringified `u128` values when crossing the NAPI boundary. Parsing failures are returned as `CommandResult::error`, not NAPI exceptions. **WHY:** NAPI has no native u128 type, and bubbling parse failures as exceptions would crash the renderer process. - -**Architecture Invariant:** `EngineLayerProvider` gives session-first layer resolution: the in-progress edit session layer shadows the persisted font layer for the currently-edited glyph. **WHY:** Composite glyphs that reference the glyph being edited must see unsaved changes (e.g. Aacute sees in-session edits to A), otherwise the canvas shows stale composites. - -**Architecture Invariant:** `DependencyGraph` is rebuilt from scratch at the end of every edit session (`end_edit_session`), not incrementally. **WHY:** Component references may have changed during the session; a full rebuild is simple and correct for the current glyph count. - -**Architecture Invariant: CRITICAL:** `set_positions` accepts `Option` -- callers must pass `null` for empty arrays, not zero-length typed arrays. **WHY:** napi-rs panics on zero-length `Float64Array`. - -## Codemap - -``` -crates/shift-node/ - src/ - lib.rs -- crate root, re-exports font_engine module - font_engine.rs -- FontEngine struct, NAPI bindings, command helpers, tests - Cargo.toml -- cdylib crate; depends on shift-core, napi, serde_json -``` - -## Key Types - -- `FontEngine` -- the single `#[napi]` class holding `Font`, `EditSession`, `DependencyGraph` -- `EngineLayerProvider` -- implements `GlyphLayerProvider` with session-first semantics for composite resolution -- `SaveFontTask` -- NAPI `Task` impl for async font saving (`save_font_async`) -- `JsNodeRef` -- tagged union (`kind` + `id` string) representing a point, anchor, or guideline across the NAPI boundary -- `JsNodePositionUpdate` -- pairs a `JsNodeRef` with `(x, y)` for batch position updates -- `GlyphHandle` -- glyph name + optional unicode for session start -- `CommandResult` (from shift-core) -- uniform JSON result shape returned by all mutations -- `parse_or_err!` -- macro that parses a string ID into a typed ID or returns a `CommandResult::error` - -## How it works - -### Command pattern - -Four internal helpers centralize the mutation-to-JSON pipeline. Each acquires the `EditSession`, runs a closure, builds a `CommandResult`, enriches it with composite contours, and serializes to JSON: - -| Helper | Closure signature | Use when | -| -------------------- | -------------------------------------------------- | ------------------------------------- | -| `command` | `&mut EditSession -> Vec` | mutation returns affected point IDs | -| `command_simple` | `&mut EditSession -> ()` | mutation has no meaningful return | -| `command_try` | `&mut EditSession -> Result, String>` | mutation can fail with a domain error | -| `command_try_simple` | `&mut EditSession -> Result<(), String>` | fallible mutation, no affected IDs | - -All four delegate to `with_command_result`, which calls `serialize_enriched_result` to attach resolved composite contours before returning the JSON string. - -### Session lifecycle - -1. JS calls `start_edit_session(GlyphHandle)`. -2. `FontEngine` calls `font.take_glyph()`, removing the glyph from the font store. It picks the most complex layer and creates an `EditSession` over it. -3. Mutations flow through the command helpers above. -4. `end_edit_session` moves the edited layer back into the glyph, puts the glyph back into the font, and rebuilds the `DependencyGraph`. - -### Composite enrichment - -Every `CommandResult` and `GlyphSnapshot` is enriched before leaving the NAPI boundary: `enrich_snapshot_with_composites` uses `EngineLayerProvider` to resolve component contours (session-first), then sets `snapshot.composite_contours`. This means the JS side always receives fully-flattened composite geometry without a second round-trip. - -### Save path - -`save_font` and `save_font_async` temporarily splice the in-session layer back into the font, clone it, then restore the original so the session is undisturbed. `save_font_async` wraps this in a `SaveFontTask` for non-blocking I/O. - -### Zero-copy bulk updates - -`set_positions` accepts `Float64Array` buffers (point IDs packed as f64, interleaved xy coords) for high-frequency drag operations, avoiding per-node NAPI object overhead. - -## Workflow recipes - -### Adding a new mutation method - -1. Add the method to `EditSession` in shift-core. -2. In `font_engine.rs`, add a `#[napi]` method on `FontEngine`. -3. Choose the right command helper (`command`, `command_try`, etc.). -4. Parse any string IDs with `parse_or_err!`. -5. Run `cargo build -p shift-node` to verify. - -### Adding a new read-only query - -1. Add a `#[napi]` method that calls through to `Font` or `EditSession`. -2. For JSON results, use `to_json(...)`. For native returns, use NAPI-compatible types directly. -3. If the query needs to see in-session state, use `editing_target_for_unicode` or `editing_target_for_name` which implement session-first resolution. - -## Gotchas - -- **Glyph removed during session**: `take_glyph` removes the glyph from the font while a session is active. Code that iterates `font.glyphs()` during a session will not see the currently-edited glyph. `editing_target_for_unicode` and `glyph_layer_by_name` handle this by checking the session first. -- **Float64Array zero-length panic**: Passing a zero-length `Float64Array` to `set_positions` causes a napi-rs panic. Always pass `null` instead. -- **ID round-tripping precision**: IDs are `u128` cast through `u64` then `f64` in `set_positions`. This is safe for current ID ranges but would silently corrupt IDs above `2^53`. -- **Composite debug logging is compiled out**: The `composite_debug!` macro expands to nothing. To enable, change the macro body to `eprintln!`. - -## Verification - -```bash -# Build the native module -cargo build -p shift-node - -# Run unit tests -cargo test -p shift-node - -# Type-check the generated TS declarations (after napi build) -npx tsc --noEmit --project apps/desktop/tsconfig.json -``` - -## Related - -- `EditSession` (shift-core) -- the Rust editing session this crate wraps -- `CommandResult`, `GlyphSnapshot` (shift-core snapshot) -- the serialization types returned across the boundary -- `GlyphLayerProvider` (shift-core composite) -- trait implemented by `EngineLayerProvider` -- `DependencyGraph` (shift-core) -- composite dependency tracking, rebuilt on session end -- `FontLoader`, `UfoWriter` (shift-core) -- font I/O used by `load_font` / `save_font` -- Preload bridge (`apps/desktop/src/preload/`) -- Electron contextBridge that exposes `FontEngine` to the renderer diff --git a/crates/shift-node/dts-header.d.ts b/crates/shift-node/dts-header.d.ts deleted file mode 100644 index 35ec3e9e..00000000 --- a/crates/shift-node/dts-header.d.ts +++ /dev/null @@ -1 +0,0 @@ -import type { ContourId, PointId, AnchorId } from "@shift/types"; diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts deleted file mode 100644 index 031043f7..00000000 --- a/crates/shift-node/index.d.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ContourId, PointId, AnchorId } from "@shift/types"; -export declare class FontEngine { - constructor() - loadFont(path: string): void - saveFont(path: string): void - getGlyphCount(): number - saveFontAsync(path: string): Promise - getMetadata(): string - getMetrics(): string - getGlyphUnicodes(): Array - getGlyphNameForUnicode(unicode: number): string | null - getDependentUnicodesByName(glyphName: string): Array - /** - * Returns SVG path data for the glyph, including resolved component - * contours from composite dependencies. - */ - getGlyphSvgPath(unicode: number): string | null - getGlyphSvgPathByName(glyphName: string): string | null - getGlyphAdvanceByName(glyphName: string): number | null - getGlyphBboxByName(glyphName: string): Array | null - getGlyphCompositeComponents(glyphName: string): string | null - isVariable(): boolean - getAxes(): string - getSources(): string - /** Returns a JSON array of master snapshots for a glyph. */ - getGlyphMasterSnapshots(glyphName: string): string | null - /** - * Bundled per-glyph fetch for the render-side `GlyphView` model. - * - * One FFI returns geometry (default master), variation deltas (or `None` - * for non-variable fonts), and component refs (names + transforms — not - * pre-flattened). The renderer constructs a reactive `GlyphView` from - * this and recurses into composites at iteration time. - */ - getGlyphData(glyphName: string): string | null - getGlyphVariationData(glyphName: string): string | null - startEditSession(glyphRef: GlyphHandle): void - endEditSession(): void - hasEditSession(): boolean - getEditingUnicode(): number | null - getEditingGlyphName(): string | null - getActiveContourId(): ContourId | null - setXAdvance(width: number): string - translateLayer(dx: number, dy: number): string - setActiveContour(contourId: string): string - clearActiveContour(): string - getSnapshotData(): string - addPoint(x: number, y: number, pointType: string, smooth: boolean): string - addPointToContour(contourId: string, x: number, y: number, pointType: string, smooth: boolean): string - insertPointBefore(beforePointId: string, x: number, y: number, pointType: string, smooth: boolean): string - addContour(): string - closeContour(): string - openContour(contourId: string): string - reverseContour(contourId: string): string - applyBooleanOp(contourIdA: string, contourIdB: string, operation: string): string - moveNodes(nodes: Array, dx: number, dy: number): string - removePoints(pointIds: Array): string - toggleSmooth(pointId: string): string - pasteContours(contoursJson: string, offsetX: number, offsetY: number): string - setNodePositions(moves: Array): boolean - /** - * Bulk position update via Float64Array. - * IDs are PointId/AnchorId u64 values packed as f64. - * Coords are interleaved [x0, y0, x1, y1, ...]. - * Bulk position update via zero-copy Float64Array. - * IDs are PointId/AnchorId u64 values packed as f64. - * Coords are interleaved [x0, y0, x1, y1, ...]. - * Pass null for empty arrays (napi-rs panics on zero-length Float64Array). - */ - setPositions(pointIds?: Float64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: Float64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): boolean - restoreSnapshot(snapshotJson: string): boolean -} - -export interface GlyphHandle { - glyphName: string - unicode?: number -} - -/** Input type for set_node_positions - a single node move. */ -export interface JsNodePositionUpdate { - node: JsNodeRef - x: number - y: number -} - -/** Tagged node reference for node-based drag/edit operations. */ -export interface JsNodeRef { - kind: 'point' | 'anchor' | 'guideline' - id: string -} diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs deleted file mode 100644 index 317a32f5..00000000 --- a/crates/shift-node/src/font_engine.rs +++ /dev/null @@ -1,1269 +0,0 @@ -use napi::bindgen_prelude::*; -use napi::{Error, Result, Status}; -use napi_derive::napi; -use shift_core::interpolation::get_glyph_variation_data; -use shift_core::{ - composite::{ - flatten_component_contours_for_layer as flatten_component_contours, layer_bbox, - layer_to_svg_path, resolve_component_instances_for_layer, resolved_to_render_contours, - GlyphLayerProvider, - }, - dependency_graph::DependencyGraph, - edit_session::EditSession, - font_loader::FontLoader, - snapshot::{ - self, AnchorSnapshot, CommandResult, ContourSnapshot, GlyphData, GlyphGeometry, GlyphSnapshot, - MasterSnapshot, RenderContourSnapshot, - }, - AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, - NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, -}; -use std::collections::HashSet; - -fn to_json(value: &impl serde::Serialize) -> String { - serde_json::to_string(value).expect("NAPI result serialization failed") -} - -macro_rules! composite_debug { - ($($arg:tt)*) => {}; -} - -macro_rules! parse_or_err { - ($id_str:expr, $ty:ty, $label:expr) => { - match $id_str.parse::<$ty>() { - Ok(id) => id, - Err(_) => { - return Ok(to_json(&CommandResult::error(format!( - concat!("Invalid ", $label, ": {}"), - $id_str - )))) - } - } - }; -} - -/// Node-side layer provider that gives precedence to the active edit session. -/// -/// This lets composite resolution observe unsaved in-session edits while still -/// falling back to persisted font layers for other glyphs. -struct EngineLayerProvider<'a> { - font: &'a Font, - current_edit_session: Option<&'a EditSession>, - editing_glyph_name: Option<&'a str>, -} - -impl GlyphLayerProvider for EngineLayerProvider<'_> { - /// Resolves a glyph layer using session-first semantics. - fn glyph_layer(&self, glyph_name: &str) -> Option<&GlyphLayer> { - if let (Some(session), Some(editing_glyph_name)) = - (self.current_edit_session, self.editing_glyph_name) - { - if editing_glyph_name == glyph_name { - return Some(session.layer()); - } - } - - let glyph = self.font.glyph(glyph_name)?; - glyph.layer(self.font.default_layer_id()) - } -} - -fn parse_ids(ids: &[String]) -> Vec { - ids.iter().filter_map(|id| id.parse::().ok()).collect() -} - -fn parse_node_ref(node: &JsNodeRef) -> Option { - match node.kind.as_str() { - "point" => node.id.parse::().ok().map(NodeRef::Point), - "anchor" => node.id.parse::().ok().map(NodeRef::Anchor), - "guideline" => node.id.parse::().ok().map(NodeRef::Guideline), - _ => None, - } -} - -pub struct SaveFontTask { - font: Font, - path: String, -} - -#[napi(object)] -pub struct GlyphHandle { - #[napi(js_name = "glyphName")] - pub glyph_name: String, - pub unicode: Option, -} - -impl Task for SaveFontTask { - type Output = (); - type JsValue = (); - - fn compute(&mut self) -> Result { - UfoWriter::new() - .save(&self.font, &self.path) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to save font: {e}"))) - } - - fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result { - Ok(()) - } -} - -#[napi] -pub struct FontEngine { - font_loader: FontLoader, - current_edit_session: Option, - editing_glyph: Option, - editing_layer_id: Option, - font: Font, - dependency_graph: DependencyGraph, -} - -impl Default for FontEngine { - fn default() -> Self { - Self::new() - } -} - -#[napi] -impl FontEngine { - #[napi(constructor)] - pub fn new() -> Self { - Self { - font_loader: FontLoader::new(), - current_edit_session: None, - editing_glyph: None, - editing_layer_id: None, - font: Font::default(), - dependency_graph: DependencyGraph::default(), - } - } - - #[napi] - pub fn load_font(&mut self, path: String) -> Result<()> { - self.font = self - .font_loader - .read_font(&path) - .map_err(|e| Error::new(Status::InvalidArg, format!("Failed to load font: {e}")))?; - self.dependency_graph = DependencyGraph::rebuild(&self.font); - Ok(()) - } - - #[napi] - pub fn save_font(&mut self, path: String) -> Result<()> { - let backup = self.apply_edits_for_save(); - let font = self.font.clone(); - self.restore_from_backup(backup); - self - .font_loader - .write_font(&font, &path) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to save font: {e}")))?; - Ok(()) - } - - #[napi] - pub fn get_glyph_count(&self) -> u32 { - self.font.glyph_count() as u32 - } - - #[napi(ts_return_type = "Promise")] - pub fn save_font_async(&mut self, path: String) -> AsyncTask { - let backup = self.apply_edits_for_save(); - let font = self.font.clone(); - self.restore_from_backup(backup); - - AsyncTask::new(SaveFontTask { font, path }) - } - - fn apply_edits_for_save(&mut self) -> Option<(String, Glyph)> { - let (session, glyph, layer_id) = match ( - &self.current_edit_session, - &self.editing_glyph, - &self.editing_layer_id, - ) { - (Some(s), Some(g), Some(l)) => (s, g, l), - _ => return None, - }; - - let glyph_name = glyph.name().to_string(); - let original = self.font.take_glyph(&glyph_name); - - let mut glyph_copy = glyph.clone(); - let layer = session.layer().clone(); - glyph_copy.set_layer(*layer_id, layer); - self.font.put_glyph(glyph_copy); - - let backup_glyph = original.unwrap_or_else(|| Glyph::new(glyph_name.clone())); - Some((glyph_name, backup_glyph)) - } - - fn restore_from_backup(&mut self, backup: Option<(String, Glyph)>) { - if let Some((_, original_glyph)) = backup { - self.font.put_glyph(original_glyph); - } - } - - fn layer_provider(&self) -> EngineLayerProvider<'_> { - EngineLayerProvider { - font: &self.font, - current_edit_session: self.current_edit_session.as_ref(), - editing_glyph_name: self.editing_glyph.as_ref().map(|glyph| glyph.name()), - } - } - - fn glyph_name_for_unicode(&self, unicode: u32) -> Option { - if let Some(session) = &self.current_edit_session { - if session.unicode() == unicode { - return Some(session.glyph_name().to_string()); - } - } - - self - .font - .glyph_by_unicode(unicode) - .map(|glyph| glyph.name().to_string()) - } - - fn collect_unicodes_for_glyph_name(&self, glyph_name: &str, out: &mut HashSet) { - if let Some(session) = &self.current_edit_session { - if session.glyph_name() == glyph_name { - out.insert(session.unicode()); - } - } - - if let Some(glyph) = self.font.glyph(glyph_name) { - out.extend(glyph.unicodes().iter().copied()); - } - } - - fn default_layer_for_glyph<'a>(&'a self, glyph: &'a Glyph) -> Option<&'a GlyphLayer> { - glyph.layer(self.font.default_layer_id()) - } - - fn editing_target_for_unicode(&self, unicode: u32) -> Option<(&str, &GlyphLayer)> { - if let Some(session) = &self.current_edit_session { - if session.unicode() == unicode { - composite_debug!( - "editing_target_for_unicode U+{:04X}: using active session '{}' (contours={}, components={}, anchors={})", - unicode, - session.glyph_name(), - session.layer().contours().len(), - session.layer().components().len(), - session.layer().anchors().len() - ); - return Some((session.glyph_name(), session.layer())); - } - } - - let glyph = self.font.glyph_by_unicode(unicode)?; - let layer = self.default_layer_for_glyph(glyph)?; - composite_debug!( - "editing_target_for_unicode U+{:04X}: from font glyph='{}' (contours={}, components={}, anchors={})", - unicode, - glyph.name(), - layer.contours().len(), - layer.components().len(), - layer.anchors().len() - ); - Some((glyph.name(), layer)) - } - - fn editing_target_for_name(&self, glyph_name: &str) -> Option<(&str, &GlyphLayer)> { - if let Some(session) = &self.current_edit_session { - if session.glyph_name() == glyph_name { - return Some((session.glyph_name(), session.layer())); - } - } - - let glyph = self.font.glyph(glyph_name)?; - let layer = self.default_layer_for_glyph(glyph)?; - Some((glyph.name(), layer)) - } - - fn glyph_layer_by_name(&self, glyph_name: &str) -> Option<&GlyphLayer> { - if let (Some(session), Some(glyph)) = (&self.current_edit_session, &self.editing_glyph) { - if glyph.name() == glyph_name { - return Some(session.layer()); - } - } - - let glyph = self.font.glyph(glyph_name)?; - self.default_layer_for_glyph(glyph) - } - - fn flatten_component_contours_for_layer( - &self, - layer: &GlyphLayer, - root_glyph_name: &str, - ) -> Vec { - let provider = self.layer_provider(); - flatten_component_contours(&provider, layer, root_glyph_name) - } - - fn resolve_component_instances_for_layer( - &self, - layer: &GlyphLayer, - root_glyph_name: &str, - ) -> Vec { - let provider = self.layer_provider(); - resolve_component_instances_for_layer(&provider, layer, root_glyph_name) - } - - fn enrich_snapshot_with_composites(&self, snapshot: &mut GlyphSnapshot) { - // Re-resolve composite geometry from the best available layer view - // (session-first, then persisted font). - let maybe_layer = if let Some(session) = &self.current_edit_session { - if session.unicode() == snapshot.unicode && session.glyph_name() == snapshot.name { - Some(session.layer()) - } else { - self.glyph_layer_by_name(&snapshot.name) - } - } else { - self.glyph_layer_by_name(&snapshot.name) - }; - - let Some(layer) = maybe_layer else { - snapshot.composite_contours.clear(); - composite_debug!( - "snapshot '{}' U+{:04X}: no layer found; composite_contours cleared", - snapshot.name, - snapshot.unicode - ); - return; - }; - - let resolved = self.flatten_component_contours_for_layer(layer, &snapshot.name); - snapshot.composite_contours = resolved_to_render_contours(&resolved); - composite_debug!( - "snapshot '{}' U+{:04X}: contours={} anchors={} composite_contours={}", - snapshot.name, - snapshot.unicode, - snapshot.contours.len(), - snapshot.anchors.len(), - snapshot.composite_contours.len() - ); - } - - fn enrich_command_result_with_composites(&self, result: &mut CommandResult) { - if let Some(snapshot) = result.snapshot.as_mut() { - self.enrich_snapshot_with_composites(snapshot); - } - } - - #[napi] - pub fn get_metadata(&self) -> String { - to_json(self.font.metadata()) - } - - #[napi] - pub fn get_metrics(&self) -> String { - to_json(self.font.metrics()) - } - - #[napi] - pub fn get_glyph_unicodes(&self) -> Vec { - let mut unicodes: Vec = self - .font - .glyphs() - .values() - .flat_map(|g| g.unicodes().iter().copied()) - .collect(); - unicodes.sort_unstable(); - unicodes.dedup(); - unicodes - } - - #[napi] - pub fn get_glyph_name_for_unicode(&self, unicode: u32) -> Option { - self.glyph_name_for_unicode(unicode) - } - - #[napi] - pub fn get_dependent_unicodes_by_name(&self, glyph_name: String) -> Vec { - let dependent_names = self.dependency_graph.dependents_recursive(&glyph_name); - let mut unicodes = HashSet::new(); - - for dependent_name in dependent_names { - self.collect_unicodes_for_glyph_name(&dependent_name, &mut unicodes); - } - - let mut sorted: Vec = unicodes.into_iter().collect(); - sorted.sort_unstable(); - sorted - } - - #[napi] - /// Returns SVG path data for the glyph, including resolved component - /// contours from composite dependencies. - pub fn get_glyph_svg_path(&self, unicode: u32) -> Option { - let (glyph_name, layer) = self.editing_target_for_unicode(unicode)?; - let component_contours = self.flatten_component_contours_for_layer(layer, glyph_name); - let path = layer_to_svg_path(layer, &component_contours); - if path.is_empty() { - composite_debug!( - "get_glyph_svg_path U+{:04X} '{}': empty path (contours={}, components={}, flattened_contours={})", - unicode, - glyph_name, - layer.contours().len(), - layer.components().len(), - component_contours.len() - ); - return None; - } - composite_debug!( - "get_glyph_svg_path U+{:04X} '{}': path chars={} (contours={}, components={}, flattened_contours={})", - unicode, - glyph_name, - path.len(), - layer.contours().len(), - layer.components().len(), - component_contours.len() - ); - Some(path) - } - - #[napi] - pub fn get_glyph_svg_path_by_name(&self, glyph_name: String) -> Option { - let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; - let component_contours = self.flatten_component_contours_for_layer(layer, resolved_name); - let path = layer_to_svg_path(layer, &component_contours); - if path.is_empty() { - return None; - } - Some(path) - } - - #[napi] - pub fn get_glyph_advance_by_name(&self, glyph_name: String) -> Option { - let (_, layer) = self.editing_target_for_name(&glyph_name)?; - Some(layer.width()) - } - - #[napi] - pub fn get_glyph_bbox_by_name(&self, glyph_name: String) -> Option> { - let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; - let component_contours = self.flatten_component_contours_for_layer(layer, resolved_name); - let bbox = layer_bbox(layer, &component_contours); - bbox.map(|(min_x, min_y, max_x, max_y)| vec![min_x, min_y, max_x, max_y]) - } - - #[napi] - pub fn get_glyph_composite_components(&self, glyph_name: String) -> Option { - let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; - let instances = self.resolve_component_instances_for_layer(layer, resolved_name); - - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct CompositeComponent { - component_glyph_name: String, - source_unicodes: Vec, - contours: Vec, - } - - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct CompositeComponents { - glyph_name: String, - components: Vec, - } - - let components = instances - .into_iter() - .map(|instance| CompositeComponent { - source_unicodes: self - .font - .glyph(&instance.component_glyph_name) - .map(|glyph| glyph.unicodes().to_vec()) - .unwrap_or_default(), - component_glyph_name: instance.component_glyph_name, - contours: resolved_to_render_contours(&instance.contours), - }) - .collect(); - - Some(to_json(&CompositeComponents { - glyph_name: resolved_name.to_string(), - components, - })) - } - - #[napi] - pub fn is_variable(&self) -> bool { - self.font.is_variable() - } - - #[napi] - pub fn get_axes(&self) -> String { - to_json(&self.font.axes()) - } - - #[napi] - pub fn get_sources(&self) -> String { - to_json(&self.font.sources()) - } - - /// Returns a JSON array of master snapshots for a glyph. - #[napi] - pub fn get_glyph_master_snapshots(&self, glyph_name: String) -> Option { - let masters = self.build_master_snapshots(&glyph_name)?; - Some(to_json(&masters)) - } - - /// Bundled per-glyph fetch for the render-side `GlyphView` model. - /// - /// One FFI returns geometry (default master), variation deltas (or `None` - /// for non-variable fonts), and component refs (names + transforms — not - /// pre-flattened). The renderer constructs a reactive `GlyphView` from - /// this and recurses into composites at iteration time. - #[napi] - pub fn get_glyph_data(&self, glyph_name: String) -> Option { - let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; - let geometry = GlyphGeometry { - x_advance: layer.width(), - contours: layer.contours_iter().map(ContourSnapshot::from).collect(), - anchors: layer.anchors_iter().map(AnchorSnapshot::from).collect(), - }; - let components: Vec = layer - .components_iter() - .map(snapshot::Component::from) - .collect(); - let variation_data = self - .build_master_snapshots(resolved_name) - .and_then(|masters| get_glyph_variation_data(&masters, self.font.axes())); - Some(to_json(&GlyphData { - geometry, - variation_data, - components, - })) - } - - #[napi] - pub fn get_glyph_variation_data(&self, glyph_name: String) -> Option { - let masters = self.build_master_snapshots(&glyph_name)?; - let axes = self.font.axes(); - let variation_data = get_glyph_variation_data(&masters, axes)?; - - Some(to_json(&variation_data)) - } - - fn build_master_snapshots(&self, glyph_name: &str) -> Option> { - let editing = self.editing_glyph_for(glyph_name); - let glyph: &Glyph = match &editing { - Some(g) => g, - None => self.font.glyph(glyph_name)?, - }; - shift_core::interpolation::build_master_snapshots(&self.font, glyph) - } - - /// If the glyph currently being edited is `glyph_name`, return a copy with the - /// in-progress session layer patched in. Otherwise `None` — caller falls back - /// to the disk copy. - fn editing_glyph_for(&self, glyph_name: &str) -> Option { - let editing = self.editing_glyph.as_ref()?; - if editing.name() != glyph_name { - return None; - } - let session = self.current_edit_session.as_ref()?; - let layer_id = *self.editing_layer_id.as_ref()?; - - let mut temp = editing.clone(); - temp.set_layer(layer_id, session.layer().clone()); - Some(temp) - } - - // ═══════════════════════════════════════════════════════════ - // EDIT SESSIONS - // ═══════════════════════════════════════════════════════════ - - fn start_edit_session_for_name( - &mut self, - glyph_name: &str, - unicode_override: Option, - ) -> Result<()> { - if self.current_edit_session.is_some() { - return Err(Error::new( - Status::GenericFailure, - "Edit session already active. End the current session first.", - )); - } - - let mut glyph = if let Some(existing) = self.font.take_glyph(glyph_name) { - existing - } else { - Glyph::new(glyph_name.to_string()) - }; - - let primary_unicode = unicode_override - .or_else(|| glyph.primary_unicode()) - .unwrap_or(0); - if primary_unicode != 0 { - glyph.add_unicode(primary_unicode); - } - - composite_debug!( - "start_edit_session '{}': layers={} primary_unicode={}", - glyph.name(), - glyph.layers().len(), - primary_unicode - ); - let default_layer_id = self.font.default_layer_id(); - let layer = glyph - .remove_layer(default_layer_id) - .unwrap_or_else(|| GlyphLayer::with_width(500.0)); - - let edit_session = EditSession::new(glyph.name().to_string(), primary_unicode, layer); - - self.current_edit_session = Some(edit_session); - self.editing_glyph = Some(glyph); - self.editing_layer_id = Some(default_layer_id); - - Ok(()) - } - - #[napi] - pub fn start_edit_session(&mut self, glyph_ref: GlyphHandle) -> Result<()> { - self.start_edit_session_for_name(&glyph_ref.glyph_name, glyph_ref.unicode) - } - - fn get_edit_session(&mut self) -> Result<&mut EditSession> { - self - .current_edit_session - .as_mut() - .ok_or(Error::new(Status::GenericFailure, "No edit session active")) - } - - fn serialize_enriched_result(&self, mut result: CommandResult) -> String { - self.enrich_command_result_with_composites(&mut result); - to_json(&result) - } - - fn with_command_result( - &mut self, - build: impl FnOnce(&mut EditSession) -> CommandResult, - ) -> Result { - let result = { - let session = self.get_edit_session()?; - build(session) - }; - Ok(self.serialize_enriched_result(result)) - } - - fn command(&mut self, f: impl FnOnce(&mut EditSession) -> Vec) -> Result { - self.with_command_result(|session| { - let ids = f(session); - CommandResult::success(session, ids) - }) - } - - fn command_simple(&mut self, f: impl FnOnce(&mut EditSession)) -> Result { - self.with_command_result(|session| { - f(session); - CommandResult::success_simple(session) - }) - } - - fn command_try( - &mut self, - f: impl FnOnce(&mut EditSession) -> std::result::Result, String>, - ) -> Result { - self.with_command_result(|session| match f(session) { - Ok(ids) => CommandResult::success(session, ids), - Err(e) => CommandResult::error(e), - }) - } - - fn command_try_simple( - &mut self, - f: impl FnOnce(&mut EditSession) -> std::result::Result<(), String>, - ) -> Result { - self.with_command_result(|session| match f(session) { - Ok(()) => CommandResult::success_simple(session), - Err(e) => CommandResult::error(e), - }) - } - - #[napi] - pub fn end_edit_session(&mut self) -> Result<()> { - let session = self - .current_edit_session - .take() - .ok_or(Error::new(Status::GenericFailure, "No edit session to end"))?; - - let mut glyph = self.editing_glyph.take().ok_or(Error::new( - Status::GenericFailure, - "No glyph stored for session", - ))?; - - let layer_id = self.editing_layer_id.take().ok_or(Error::new( - Status::GenericFailure, - "No layer ID stored for session", - ))?; - - let session_unicode = session.unicode(); - let layer = session.into_layer(); - glyph.set_layer(layer_id, layer); - if session_unicode != 0 { - glyph.add_unicode(session_unicode); - } - self.font.put_glyph(glyph); - self.dependency_graph = DependencyGraph::rebuild(&self.font); - - Ok(()) - } - - #[napi] - pub fn has_edit_session(&self) -> bool { - self.current_edit_session.is_some() - } - - #[napi] - pub fn get_editing_unicode(&self) -> Option { - self.current_edit_session.as_ref().map(|s| s.unicode()) - } - - #[napi] - pub fn get_editing_glyph_name(&self) -> Option { - self - .current_edit_session - .as_ref() - .map(|s| s.glyph_name().to_string()) - } - - #[napi(ts_return_type = "ContourId | null")] - pub fn get_active_contour_id(&mut self) -> Result> { - let edit_session = self.get_edit_session()?; - Ok(edit_session.active_contour_id().map(|id| id.to_string())) - } - - #[napi] - pub fn set_x_advance(&mut self, width: f64) -> Result { - self.command_simple(|s| s.set_x_advance(width)) - } - - #[napi] - pub fn translate_layer(&mut self, dx: f64, dy: f64) -> Result { - self.command_simple(|s| s.translate_layer(dx, dy)) - } - - #[napi] - pub fn set_active_contour(&mut self, contour_id: String) -> Result { - let cid = parse_or_err!(contour_id, ContourId, "contour ID"); - self.command_simple(|s| s.set_active_contour(cid)) - } - - #[napi] - pub fn clear_active_contour(&mut self) -> Result { - self.command_simple(|s| s.clear_active_contour()) - } - - #[napi] - pub fn get_snapshot_data(&self) -> Result { - let session = self - .current_edit_session - .as_ref() - .ok_or_else(|| Error::new(Status::GenericFailure, "No edit session active"))?; - - let mut snapshot = GlyphSnapshot::from_edit_session(session); - self.enrich_snapshot_with_composites(&mut snapshot); - composite_debug!( - "get_snapshot_data '{}' U+{:04X}: contours={} anchors={} composite_contours={}", - snapshot.name, - snapshot.unicode, - snapshot.contours.len(), - snapshot.anchors.len(), - snapshot.composite_contours.len() - ); - - Ok(to_json(&snapshot)) - } - - #[napi] - pub fn add_point(&mut self, x: f64, y: f64, point_type: String, smooth: bool) -> Result { - let pt = parse_or_err!(point_type, PointType, "point type"); - self.command_try(|s| s.add_point(x, y, pt, smooth).map(|id| vec![id])) - } - - #[napi] - pub fn add_point_to_contour( - &mut self, - contour_id: String, - x: f64, - y: f64, - point_type: String, - smooth: bool, - ) -> Result { - let pt = parse_or_err!(point_type, PointType, "point type"); - let cid = parse_or_err!(contour_id, ContourId, "contour ID"); - self.command_try(|s| { - s.add_point_to_contour(cid, x, y, pt, smooth) - .map(|id| vec![id]) - }) - } - - #[napi] - pub fn insert_point_before( - &mut self, - before_point_id: String, - x: f64, - y: f64, - point_type: String, - smooth: bool, - ) -> Result { - let pt = parse_or_err!(point_type, PointType, "point type"); - let before_id = parse_or_err!(before_point_id, PointId, "point ID"); - self.command_try(|s| { - s.insert_point_before(before_id, x, y, pt, smooth) - .map(|id| vec![id]) - }) - } - - #[napi] - pub fn add_contour(&mut self) -> Result { - self.command_simple(|s| { - s.add_empty_contour(); - }) - } - - #[napi] - pub fn close_contour(&mut self) -> Result { - let result = { - let session = self.get_edit_session()?; - - let contour_id = match session.active_contour_id() { - Some(id) => id, - None => return Ok(to_json(&CommandResult::error("No active contour"))), - }; - - match session.close_contour(contour_id) { - Ok(_) => CommandResult::success_simple(session), - Err(e) => CommandResult::error(e), - } - }; - - Ok(self.serialize_enriched_result(result)) - } - - #[napi] - pub fn open_contour(&mut self, contour_id: String) -> Result { - let cid = parse_or_err!(contour_id, ContourId, "contour ID"); - self.command_try_simple(|s| s.open_contour(cid)) - } - - #[napi] - pub fn reverse_contour(&mut self, contour_id: String) -> Result { - let cid = parse_or_err!(contour_id, ContourId, "contour ID"); - self.command_try_simple(|s| s.reverse_contour(cid)) - } - - #[napi] - pub fn apply_boolean_op( - &mut self, - contour_id_a: String, - contour_id_b: String, - operation: String, - ) -> Result { - let cid_a = parse_or_err!(contour_id_a, ContourId, "contour ID A"); - let cid_b = parse_or_err!(contour_id_b, ContourId, "contour ID B"); - - let op = match operation.as_str() { - "union" => BooleanOp::Union, - "subtract" => BooleanOp::Subtract, - "intersect" => BooleanOp::Intersect, - "difference" => BooleanOp::Difference, - _ => { - return Ok(to_json(&CommandResult::error(format!( - "Unknown boolean operation: {operation}" - )))) - } - }; - - self.with_command_result(|session| match session.apply_boolean_op(cid_a, cid_b, op) { - Ok(_ids) => CommandResult::success_simple(session), - Err(e) => CommandResult::error(e), - }) - } - - #[napi] - pub fn move_nodes(&mut self, nodes: Vec, dx: f64, dy: f64) -> Result { - let parsed_nodes: Vec = nodes.iter().filter_map(parse_node_ref).collect(); - - if parsed_nodes.is_empty() && !nodes.is_empty() { - return Ok(to_json(&CommandResult::error("No valid node IDs provided"))); - } - - self.command(|s| s.move_nodes(&parsed_nodes, dx, dy)) - } - - #[napi] - pub fn remove_points(&mut self, point_ids: Vec) -> Result { - let parsed_ids: Vec = parse_ids(&point_ids); - - if parsed_ids.is_empty() && !point_ids.is_empty() { - return Ok(to_json(&CommandResult::error( - "No valid point IDs provided", - ))); - } - - self.command(|s| s.remove_points(&parsed_ids)) - } - - #[napi] - pub fn toggle_smooth(&mut self, point_id: String) -> Result { - let parsed_id = parse_or_err!(point_id, PointId, "point ID"); - self.command_try(|s| s.toggle_smooth(parsed_id).map(|_| vec![parsed_id])) - } - - #[napi] - pub fn paste_contours( - &mut self, - contours_json: String, - offset_x: f64, - offset_y: f64, - ) -> Result { - let session = self.get_edit_session()?; - - let contours: Vec = match serde_json::from_str(&contours_json) { - Ok(c) => c, - Err(e) => { - return Ok(to_json(&PasteResultJson { - success: false, - created_point_ids: vec![], - created_contour_ids: vec![], - error: Some(format!("Failed to parse contours: {e}")), - })) - } - }; - - let result = session.paste_contours(contours, offset_x, offset_y); - - Ok(to_json(&PasteResultJson { - success: result.success, - created_point_ids: result - .created_point_ids - .iter() - .map(|id| id.to_string()) - .collect(), - created_contour_ids: result - .created_contour_ids - .iter() - .map(|id| id.to_string()) - .collect(), - error: result.error, - })) - } - - #[napi] - pub fn set_node_positions(&mut self, moves: Vec) -> Result { - let Some(session) = self.current_edit_session.as_mut() else { - return Ok(false); - }; - - let mut updates = Vec::new(); - for m in moves { - let Some(node) = parse_node_ref(&m.node) else { - continue; - }; - - updates.push(NodePositionUpdate { - node, - x: m.x, - y: m.y, - }); - } - - if updates.is_empty() { - return Ok(true); - } - - Ok(session.set_node_positions(&updates)) - } - - /// Bulk position update via Float64Array. - /// IDs are PointId/AnchorId u64 values packed as f64. - /// Coords are interleaved [x0, y0, x1, y1, ...]. - /// Bulk position update via zero-copy Float64Array. - /// IDs are PointId/AnchorId u64 values packed as f64. - /// Coords are interleaved [x0, y0, x1, y1, ...]. - /// Pass null for empty arrays (napi-rs panics on zero-length Float64Array). - #[napi] - pub fn set_positions( - &mut self, - point_ids: Option, - point_coords: Option, - anchor_ids: Option, - anchor_coords: Option, - ) -> Result { - let Some(session) = self.current_edit_session.as_mut() else { - return Ok(false); - }; - - let mut updates = Vec::new(); - - if let (Some(pids), Some(pcoords)) = (&point_ids, &point_coords) { - let id_slice: &[f64] = pids; - let coord_slice: &[f64] = pcoords; - - updates.reserve(id_slice.len()); - for (i, &id) in id_slice.iter().enumerate() { - updates.push(NodePositionUpdate { - node: NodeRef::Point(PointId::from_raw(id as u64 as u128)), - x: coord_slice[i * 2], - y: coord_slice[i * 2 + 1], - }); - } - } - - if let (Some(aids), Some(acoords)) = (&anchor_ids, &anchor_coords) { - let id_slice: &[f64] = aids; - let coord_slice: &[f64] = acoords; - - updates.reserve(id_slice.len()); - for (i, &id) in id_slice.iter().enumerate() { - updates.push(NodePositionUpdate { - node: NodeRef::Anchor(AnchorId::from_raw(id as u64 as u128)), - x: coord_slice[i * 2], - y: coord_slice[i * 2 + 1], - }); - } - } - - if updates.is_empty() { - return Ok(true); - } - - Ok(session.set_node_positions(&updates)) - } - - #[napi] - pub fn restore_snapshot(&mut self, snapshot_json: String) -> Result { - let Some(session) = self.current_edit_session.as_mut() else { - return Ok(false); - }; - - let snapshot: GlyphSnapshot = serde_json::from_str(&snapshot_json).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Invalid snapshot JSON: {e}"), - ) - })?; - - session.restore_from_snapshot(&snapshot); - Ok(true) - } -} - -/// Tagged node reference for node-based drag/edit operations. -#[napi(object)] -pub struct JsNodeRef { - #[napi(ts_type = "'point' | 'anchor' | 'guideline'")] - pub kind: String, - pub id: String, -} - -/// Input type for set_node_positions - a single node move. -#[napi(object)] -pub struct JsNodePositionUpdate { - pub node: JsNodeRef, - pub x: f64, - pub y: f64, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct PasteResultJson { - success: bool, - created_point_ids: Vec, - created_contour_ids: Vec, - error: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_font_engine() { - let engine = FontEngine::new(); - assert!(!engine.has_edit_session()); - assert_eq!(engine.get_glyph_count(), 0); - } - - #[test] - fn test_start_and_end_session() { - let mut engine = FontEngine::new(); - - engine - .start_edit_session(GlyphHandle { - glyph_name: "A".to_string(), - unicode: Some(65), - }) - .unwrap(); - assert!(engine.has_edit_session()); - assert_eq!(engine.get_editing_unicode(), Some(65)); - - engine.end_edit_session().unwrap(); - assert!(!engine.has_edit_session()); - assert_eq!(engine.get_editing_unicode(), None); - assert_eq!(engine.get_glyph_name_for_unicode(65), Some("A".to_string())); - } - - #[test] - fn test_start_session_preserves_unicode_mapping() { - let mut engine = FontEngine::new(); - - engine - .start_edit_session(GlyphHandle { - glyph_name: "A".to_string(), - unicode: Some(65), - }) - .unwrap(); - assert_eq!(engine.get_editing_unicode(), Some(65)); - assert_eq!(engine.get_editing_glyph_name(), Some("A".to_string())); - - engine.end_edit_session().unwrap(); - assert_eq!(engine.get_glyph_name_for_unicode(65), Some("A".to_string())); - } - - #[test] - fn test_cannot_start_second_session() { - let mut engine = FontEngine::new(); - - engine - .start_edit_session(GlyphHandle { - glyph_name: "A".to_string(), - unicode: Some(65), - }) - .unwrap(); - let result = engine.start_edit_session(GlyphHandle { - glyph_name: "B".to_string(), - unicode: Some(66), - }); - - assert!(result.is_err()); - } - - #[test] - fn test_add_contour() { - let mut engine = FontEngine::new(); - engine - .start_edit_session(GlyphHandle { - glyph_name: "A".to_string(), - unicode: Some(65), - }) - .unwrap(); - - let contour_id = engine.add_contour().unwrap(); - assert!(!contour_id.is_empty()); - - let active = engine.get_active_contour_id().unwrap(); - assert_eq!(active, Some(contour_id)); - } - - #[test] - fn test_get_glyph_svg_path_after_load() { - let ufo_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); - if !ufo_path.exists() { - return; - } - let path_str = ufo_path.to_str().unwrap(); - let mut engine = FontEngine::new(); - engine.load_font(path_str.to_string()).unwrap(); - let path = engine.get_glyph_svg_path(65); - assert!( - path.is_some(), - "get_glyph_svg_path(65) should return Some for MutatorSans A" - ); - let path = path.unwrap(); - assert!(!path.is_empty()); - assert!(path.starts_with("M ")); - } - - #[test] - fn test_get_glyph_svg_path_for_composite_after_load() { - let ufo_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); - if !ufo_path.exists() { - return; - } - let path_str = ufo_path.to_str().unwrap(); - let mut engine = FontEngine::new(); - engine.load_font(path_str.to_string()).unwrap(); - let path = engine.get_glyph_svg_path(0x00C1); - assert!( - path.is_some(), - "get_glyph_svg_path(U+00C1) should return Some for MutatorSans Aacute" - ); - let path = path.unwrap(); - assert!(!path.is_empty()); - assert!(path.starts_with("M ")); - } - - #[test] - fn test_get_dependent_unicodes_includes_aacute_for_a() { - let ufo_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); - if !ufo_path.exists() { - return; - } - let path_str = ufo_path.to_str().unwrap(); - let mut engine = FontEngine::new(); - engine.load_font(path_str.to_string()).unwrap(); - engine - .start_edit_session(GlyphHandle { - glyph_name: "A".to_string(), - unicode: Some(65), - }) - .unwrap(); - - let dependents = engine.get_dependent_unicodes_by_name("A".to_string()); - assert!( - dependents.contains(&0x00C1), - "Expected U+00C1 (Aacute) to depend on U+0041 (A), got {dependents:?}" - ); - } - - #[test] - fn test_get_glyph_bbox_after_load() { - let ufo_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); - if !ufo_path.exists() { - return; - } - let path_str = ufo_path.to_str().unwrap(); - let mut engine = FontEngine::new(); - engine.load_font(path_str.to_string()).unwrap(); - let bbox = engine.get_glyph_bbox_by_name("A".to_string()); - assert!( - bbox.is_some(), - "get_glyph_bbox_by_name(A) should return Some for MutatorSans A" - ); - let b = bbox.unwrap(); - assert_eq!(b.len(), 4); - assert!(b[0] < b[2], "min_x < max_x"); - assert!(b[1] < b[3], "min_y < max_y"); - } - - #[test] - fn test_snapshot_data_includes_composite_contours() { - let ufo_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); - if !ufo_path.exists() { - return; - } - let path_str = ufo_path.to_str().unwrap(); - let mut engine = FontEngine::new(); - engine.load_font(path_str.to_string()).unwrap(); - engine - .start_edit_session(GlyphHandle { - glyph_name: "Aacute".to_string(), - unicode: Some(0x00C1), - }) - .unwrap(); - - let snapshot_json = engine.get_snapshot_data().unwrap(); - let snapshot: GlyphSnapshot = serde_json::from_str(&snapshot_json).unwrap(); - assert!( - !snapshot.composite_contours.is_empty(), - "Aacute snapshot should include flattened compositeContours" - ); - } -} diff --git a/crates/shift-node/src/lib.rs b/crates/shift-node/src/lib.rs deleted file mode 100644 index b5fb0a4f..00000000 --- a/crates/shift-node/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -mod font_engine; diff --git a/crates/shift-wire/Cargo.toml b/crates/shift-wire/Cargo.toml new file mode 100644 index 00000000..f22a4d7f --- /dev/null +++ b/crates/shift-wire/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition = "2021" +name = "shift-wire" +version = "0.0.0" +authors = ["Kostya Farber "] +license = "MIT" + +[lib] +crate-type = ["rlib"] + +[features] +default = [] +napi = ["dep:napi", "dep:napi-derive"] + +[dependencies] +shift-ir = { workspace = true } + +napi = { version = "=3.8.6", optional = true, default-features = false, features = [ + "napi6", +] } +napi-derive = { version = "=3.5.5", optional = true } +serde = { version = "1.0.219", features = ["derive"] } diff --git a/crates/shift-wire/src/bridges/mod.rs b/crates/shift-wire/src/bridges/mod.rs new file mode 100644 index 00000000..47708687 --- /dev/null +++ b/crates/shift-wire/src/bridges/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "napi")] +pub mod napi; diff --git a/crates/shift-wire/src/bridges/napi/mod.rs b/crates/shift-wire/src/bridges/napi/mod.rs new file mode 100644 index 00000000..07ab5563 --- /dev/null +++ b/crates/shift-wire/src/bridges/napi/mod.rs @@ -0,0 +1,489 @@ +use std::collections::HashMap; + +use napi::bindgen_prelude::Float64Array; +use napi_derive::napi; +use shift_ir::PointType as IrPointType; + +use crate::{ + AnchorData, Axis, AxisTent, ComponentData, ContourData, FontMetadata, FontMetrics, + GlyphChangedEntities, GlyphMaster, GlyphRecord, GlyphState, GlyphStructure, + GlyphStructureChange, GlyphValueChange, GlyphVariationData, Location, PointData, PointType, + Source, +}; + +#[napi(string_enum = "camelCase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NapiPointType { + OnCurve, + OffCurve, +} + +impl From for NapiPointType { + fn from(point_type: PointType) -> Self { + match point_type { + PointType::OnCurve => Self::OnCurve, + PointType::OffCurve => Self::OffCurve, + } + } +} + +impl From for PointType { + fn from(point_type: NapiPointType) -> Self { + match point_type { + NapiPointType::OnCurve => Self::OnCurve, + NapiPointType::OffCurve => Self::OffCurve, + } + } +} + +impl From for IrPointType { + fn from(point_type: NapiPointType) -> Self { + let point_type: PointType = point_type.into(); + point_type.into() + } +} + +#[napi(object)] +pub struct NapiFontMetadata { + pub family_name: Option, + pub style_name: Option, + pub version_major: Option, + pub version_minor: Option, + pub copyright: Option, + pub trademark: Option, + pub designer: Option, + pub designer_url: Option, + pub manufacturer: Option, + pub manufacturer_url: Option, + pub license: Option, + pub license_url: Option, + pub description: Option, + pub note: Option, +} + +impl From for NapiFontMetadata { + fn from(metadata: FontMetadata) -> Self { + Self { + family_name: metadata.family_name, + style_name: metadata.style_name, + version_major: metadata.version_major, + version_minor: metadata.version_minor, + copyright: metadata.copyright, + trademark: metadata.trademark, + designer: metadata.designer, + designer_url: metadata.designer_url, + manufacturer: metadata.manufacturer, + manufacturer_url: metadata.manufacturer_url, + license: metadata.license, + license_url: metadata.license_url, + description: metadata.description, + note: metadata.note, + } + } +} + +#[napi(object)] +pub struct NapiFontMetrics { + pub units_per_em: f64, + pub ascender: f64, + pub descender: f64, + pub cap_height: Option, + pub x_height: Option, + pub line_gap: Option, + pub italic_angle: Option, + pub underline_position: Option, + pub underline_thickness: Option, +} + +impl From for NapiFontMetrics { + fn from(metrics: FontMetrics) -> Self { + Self { + units_per_em: metrics.units_per_em, + ascender: metrics.ascender, + descender: metrics.descender, + cap_height: metrics.cap_height, + x_height: metrics.x_height, + line_gap: metrics.line_gap, + italic_angle: metrics.italic_angle, + underline_position: metrics.underline_position, + underline_thickness: metrics.underline_thickness, + } + } +} + +#[napi(object)] +pub struct NapiAxis { + pub tag: String, + pub name: String, + pub minimum: f64, + pub default: f64, + pub maximum: f64, + pub hidden: bool, +} + +impl From for NapiAxis { + fn from(axis: Axis) -> Self { + Self { + tag: axis.tag, + name: axis.name, + minimum: axis.minimum, + default: axis.default, + maximum: axis.maximum, + hidden: axis.hidden, + } + } +} + +#[napi(object)] +pub struct NapiSource { + #[napi(ts_type = "SourceId")] + pub id: String, + pub name: String, + pub location: NapiLocation, + #[napi(ts_type = "LayerId")] + pub layer_id: String, + pub filename: Option, +} + +impl From for NapiSource { + fn from(source: Source) -> Self { + Self { + id: source.id.to_string(), + name: source.name, + location: source.location.into(), + layer_id: source.layer_id.to_string(), + filename: source.filename, + } + } +} + +#[napi(object)] +pub struct NapiGlyphRecord { + #[napi(ts_type = "GlyphName")] + pub name: String, + #[napi(ts_type = "Array")] + pub unicodes: Vec, + #[napi(ts_type = "Array")] + pub component_base_glyph_names: Vec, +} + +impl From for NapiGlyphRecord { + fn from(record: GlyphRecord) -> Self { + Self { + name: record.name.to_string(), + unicodes: record.unicodes, + component_base_glyph_names: record + .component_base_glyph_names + .into_iter() + .map(|name| name.to_string()) + .collect(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphState { + pub structure: NapiGlyphStructure, + /// Numeric glyph state ordered to match `GlyphStructure`. + pub values: Float64Array, + pub variation_data: Option, +} + +impl From for NapiGlyphState { + fn from(state: GlyphState) -> Self { + Self { + structure: state.structure.into(), + values: state.values.into(), + variation_data: state.variation_data.map(Into::into), + } + } +} + +#[napi(object)] +pub struct NapiGlyphStructure { + pub contours: Vec, + pub anchors: Vec, + pub components: Vec, +} + +impl From for NapiGlyphStructure { + fn from(structure: GlyphStructure) -> Self { + Self { + contours: structure.contours.into_iter().map(Into::into).collect(), + anchors: structure.anchors.into_iter().map(Into::into).collect(), + components: structure.components.into_iter().map(Into::into).collect(), + } + } +} + +impl From for GlyphStructure { + fn from(structure: NapiGlyphStructure) -> Self { + Self { + contours: structure.contours.into_iter().map(Into::into).collect(), + anchors: structure.anchors.into_iter().map(Into::into).collect(), + components: structure.components.into_iter().map(Into::into).collect(), + } + } +} + +#[napi(object)] +pub struct NapiContourData { + #[napi(ts_type = "ContourId")] + pub id: String, + pub points: Vec, + pub closed: bool, +} + +impl From for NapiContourData { + fn from(contour: ContourData) -> Self { + Self { + id: contour.id, + points: contour.points.into_iter().map(Into::into).collect(), + closed: contour.closed, + } + } +} + +impl From for ContourData { + fn from(contour: NapiContourData) -> Self { + Self { + id: contour.id, + points: contour.points.into_iter().map(Into::into).collect(), + closed: contour.closed, + } + } +} + +#[napi(object)] +pub struct NapiPointData { + #[napi(ts_type = "PointId")] + pub id: String, + pub point_type: NapiPointType, + pub smooth: bool, +} + +impl From for NapiPointData { + fn from(point: PointData) -> Self { + Self { + id: point.id, + point_type: point.point_type.into(), + smooth: point.smooth, + } + } +} + +impl From for PointData { + fn from(point: NapiPointData) -> Self { + Self { + id: point.id, + point_type: point.point_type.into(), + smooth: point.smooth, + } + } +} + +#[napi(object)] +pub struct NapiAnchorData { + #[napi(ts_type = "AnchorId")] + pub id: String, + pub name: Option, +} + +impl From for NapiAnchorData { + fn from(anchor: AnchorData) -> Self { + Self { + id: anchor.id, + name: anchor.name, + } + } +} + +impl From for AnchorData { + fn from(anchor: NapiAnchorData) -> Self { + Self { + id: anchor.id, + name: anchor.name, + } + } +} + +#[napi(object)] +pub struct NapiComponentData { + #[napi(ts_type = "ComponentId")] + pub id: String, + #[napi(ts_type = "GlyphName")] + pub base_glyph_name: String, +} + +impl From for NapiComponentData { + fn from(component: ComponentData) -> Self { + Self { + id: component.id, + base_glyph_name: component.base_glyph_name.to_string(), + } + } +} + +impl From for ComponentData { + fn from(component: NapiComponentData) -> Self { + Self { + id: component.id, + base_glyph_name: component.base_glyph_name.into(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphChangedEntities { + #[napi(ts_type = "Array")] + pub point_ids: Vec, + #[napi(ts_type = "Array")] + pub contour_ids: Vec, + #[napi(ts_type = "Array")] + pub anchor_ids: Vec, + #[napi(ts_type = "Array")] + pub guideline_ids: Vec, + #[napi(ts_type = "Array")] + pub component_ids: Vec, +} + +impl From for NapiGlyphChangedEntities { + fn from(entities: GlyphChangedEntities) -> Self { + Self { + point_ids: entities + .point_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + contour_ids: entities + .contour_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + anchor_ids: entities + .anchor_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + guideline_ids: entities + .guideline_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + component_ids: entities + .component_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphValueChange { + pub values: Float64Array, + pub changed: NapiGlyphChangedEntities, +} + +impl From for NapiGlyphValueChange { + fn from(change: GlyphValueChange) -> Self { + Self { + values: change.values.into(), + changed: change.changed.into(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphStructureChange { + pub structure: NapiGlyphStructure, + pub values: Float64Array, + pub changed: NapiGlyphChangedEntities, +} + +impl From for NapiGlyphStructureChange { + fn from(change: GlyphStructureChange) -> Self { + Self { + structure: change.structure.into(), + values: change.values.into(), + changed: change.changed.into(), + } + } +} + +#[napi(object)] +pub struct NapiLocation { + pub values: HashMap, +} + +impl From for NapiLocation { + fn from(location: Location) -> Self { + Self { + values: location.values, + } + } +} + +#[napi(object)] +pub struct NapiAxisTent { + pub axis_tag: String, + pub lower: f64, + pub peak: f64, + pub upper: f64, +} + +impl From for NapiAxisTent { + fn from(tent: AxisTent) -> Self { + Self { + axis_tag: tent.axis_tag, + lower: tent.lower, + peak: tent.peak, + upper: tent.upper, + } + } +} + +#[napi(object)] +pub struct NapiGlyphVariationData { + /// One entry per region. Inner = tents on the axes the region depends on. + pub regions: Vec>, + /// Deltas are flattened in `GlyphState::values` order. + pub deltas: Vec, +} + +impl From for NapiGlyphVariationData { + fn from(data: GlyphVariationData) -> Self { + Self { + regions: data + .regions + .into_iter() + .map(|region| region.into_iter().map(Into::into).collect()) + .collect(), + deltas: data.deltas.into_iter().map(Into::into).collect(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphMaster { + #[napi(ts_type = "SourceId")] + pub source_id: String, + pub source_name: String, + pub is_default_source: bool, + pub location: NapiLocation, + pub structure: NapiGlyphStructure, + pub values: Float64Array, +} + +impl From for NapiGlyphMaster { + fn from(master: GlyphMaster) -> Self { + Self { + source_id: master.source_id, + source_name: master.source_name, + is_default_source: master.is_default_source, + location: master.location.into(), + structure: master.structure.into(), + values: master.values.into(), + } + } +} diff --git a/crates/shift-wire/src/lib.rs b/crates/shift-wire/src/lib.rs new file mode 100644 index 00000000..23824e1b --- /dev/null +++ b/crates/shift-wire/src/lib.rs @@ -0,0 +1,494 @@ +//! document state types shared by edit logic and bridge bindings. +//! +//! These types split stable glyph structure from mutable numeric values. The +//! values layout is canonical and must stay in lockstep with every consumer. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use shift_ir::{ + Anchor as IrAnchor, AnchorId, Axis as IrAxis, Component as IrComponent, ComponentId, + Contour as IrContour, ContourId, DecomposedTransform as IrTransform, + FontMetadata as IrFontMetadata, FontMetrics as IrFontMetrics, Glyph as IrGlyph, GlyphLayer, + GlyphName, GuidelineId, LayerId, Location as IrLocation, Point as IrPoint, PointId, + PointType as IrPointType, Source as IrSource, SourceId, +}; + +pub mod bridges; + +/// Flat numeric glyph values ordered to match `GlyphStructure`. +/// +/// This layout is structure-dependent: +/// +/// 1. x advance +/// 2. contour point positions, in `GlyphStructure.contours` order: +/// `x, y` for each point +/// 3. anchor positions, in `GlyphStructure.anchors` order: +/// `x, y` for each anchor +/// 4. component transforms, in `GlyphStructure.components` order: +/// `translateX, translateY, rotation, scaleX, scaleY, +/// skewX, skewY, tCenterX, tCenterY` for each component +pub type GlyphValue = f64; + +pub type GlyphValues = Vec; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FontMetadata { + pub family_name: Option, + pub style_name: Option, + pub version_major: Option, + pub version_minor: Option, + pub copyright: Option, + pub trademark: Option, + pub designer: Option, + pub designer_url: Option, + pub manufacturer: Option, + pub manufacturer_url: Option, + pub license: Option, + pub license_url: Option, + pub description: Option, + pub note: Option, +} + +impl From<&IrFontMetadata> for FontMetadata { + fn from(metadata: &IrFontMetadata) -> Self { + Self { + family_name: metadata.family_name.clone(), + style_name: metadata.style_name.clone(), + version_major: metadata.version_major, + version_minor: metadata.version_minor, + copyright: metadata.copyright.clone(), + trademark: metadata.trademark.clone(), + designer: metadata.designer.clone(), + designer_url: metadata.designer_url.clone(), + manufacturer: metadata.manufacturer.clone(), + manufacturer_url: metadata.manufacturer_url.clone(), + license: metadata.license.clone(), + license_url: metadata.license_url.clone(), + description: metadata.description.clone(), + note: metadata.note.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FontMetrics { + pub units_per_em: f64, + pub ascender: f64, + pub descender: f64, + pub cap_height: Option, + pub x_height: Option, + pub line_gap: Option, + pub italic_angle: Option, + pub underline_position: Option, + pub underline_thickness: Option, +} + +impl From<&IrFontMetrics> for FontMetrics { + fn from(metrics: &IrFontMetrics) -> Self { + Self { + units_per_em: metrics.units_per_em, + ascender: metrics.ascender, + descender: metrics.descender, + cap_height: metrics.cap_height, + x_height: metrics.x_height, + line_gap: metrics.line_gap, + italic_angle: metrics.italic_angle, + underline_position: metrics.underline_position, + underline_thickness: metrics.underline_thickness, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Axis { + pub tag: String, + pub name: String, + pub minimum: f64, + pub default: f64, + pub maximum: f64, + pub hidden: bool, +} + +impl From<&IrAxis> for Axis { + fn from(axis: &IrAxis) -> Self { + Self { + tag: axis.tag().to_string(), + name: axis.name().to_string(), + minimum: axis.minimum(), + default: axis.default(), + maximum: axis.maximum(), + hidden: axis.is_hidden(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Source { + pub id: SourceId, + pub name: String, + pub location: Location, + pub layer_id: LayerId, + pub filename: Option, +} + +impl From<&IrSource> for Source { + fn from(source: &IrSource) -> Self { + Self { + id: source.id(), + name: source.name().to_string(), + location: source.location().into(), + layer_id: source.layer_id(), + filename: source.filename().map(str::to_string), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphRecord { + pub name: GlyphName, + pub unicodes: Vec, + pub component_base_glyph_names: Vec, +} + +impl From<&IrGlyph> for GlyphRecord { + fn from(glyph: &IrGlyph) -> Self { + let mut component_base_glyph_names: Vec<_> = glyph + .layers() + .values() + .flat_map(|layer| layer.components_iter()) + .map(|component| component.base_glyph().clone()) + .collect(); + component_base_glyph_names.sort(); + component_base_glyph_names.dedup(); + + Self { + name: glyph.glyph_name().clone(), + unicodes: glyph.unicodes().to_vec(), + component_base_glyph_names, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphState { + pub structure: GlyphStructure, + /// Numeric glyph state ordered to match `GlyphStructure`. + pub values: GlyphValues, + pub variation_data: Option, +} + +impl GlyphState { + pub fn from_layer(layer: &GlyphLayer, variation_data: Option) -> Self { + Self { + structure: GlyphStructure::from(layer), + values: values_from_layer(layer), + variation_data, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphStructure { + pub contours: Vec, + pub anchors: Vec, + pub components: Vec, +} + +impl From<&GlyphLayer> for GlyphStructure { + fn from(layer: &GlyphLayer) -> Self { + Self { + contours: layer.contours_iter().map(ContourData::from).collect(), + anchors: layer.anchors_iter().map(AnchorData::from).collect(), + components: sorted_components(layer) + .into_iter() + .map(ComponentData::from) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContourData { + pub id: String, + pub points: Vec, + pub closed: bool, +} + +impl From<&IrContour> for ContourData { + fn from(contour: &IrContour) -> Self { + Self { + id: contour.id().to_string(), + points: contour.points().iter().map(PointData::from).collect(), + closed: contour.is_closed(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PointData { + pub id: String, + pub point_type: PointType, + pub smooth: bool, +} + +impl From<&IrPoint> for PointData { + fn from(point: &IrPoint) -> Self { + Self { + id: point.id().to_string(), + point_type: point.point_type().into(), + smooth: point.is_smooth(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PointType { + OnCurve, + OffCurve, +} + +impl From for PointType { + fn from(point_type: IrPointType) -> Self { + match point_type { + IrPointType::OnCurve | IrPointType::QCurve => Self::OnCurve, + IrPointType::OffCurve => Self::OffCurve, + } + } +} + +impl From for IrPointType { + fn from(point_type: PointType) -> Self { + match point_type { + PointType::OffCurve => IrPointType::OffCurve, + PointType::OnCurve => IrPointType::OnCurve, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorData { + pub id: String, + pub name: Option, +} + +impl From<&IrAnchor> for AnchorData { + fn from(anchor: &IrAnchor) -> Self { + Self { + id: anchor.id().to_string(), + name: anchor.name().map(str::to_owned), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentData { + pub id: String, + pub base_glyph_name: GlyphName, +} + +impl From<&IrComponent> for ComponentData { + fn from(component: &IrComponent) -> Self { + Self { + id: component.id().to_string(), + base_glyph_name: component.base_glyph().clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GlyphChangedEntities { + pub point_ids: Vec, + pub contour_ids: Vec, + pub anchor_ids: Vec, + pub guideline_ids: Vec, + pub component_ids: Vec, +} + +impl GlyphChangedEntities { + pub fn point(id: PointId) -> Self { + Self { + point_ids: vec![id], + ..Default::default() + } + } + + pub fn points(ids: Vec) -> Self { + Self { + point_ids: ids, + ..Default::default() + } + } + + pub fn contour(id: ContourId) -> Self { + Self { + contour_ids: vec![id], + ..Default::default() + } + } + + pub fn contours(ids: Vec) -> Self { + Self { + contour_ids: ids, + ..Default::default() + } + } + + pub fn anchor(id: AnchorId) -> Self { + Self { + anchor_ids: vec![id], + ..Default::default() + } + } + + pub fn guideline(id: GuidelineId) -> Self { + Self { + guideline_ids: vec![id], + ..Default::default() + } + } + + pub fn component(id: ComponentId) -> Self { + Self { + component_ids: vec![id], + ..Default::default() + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphValueChange { + pub values: GlyphValues, + pub changed: GlyphChangedEntities, +} + +impl GlyphValueChange { + pub fn from_layer(layer: &GlyphLayer, changed: GlyphChangedEntities) -> Self { + Self { + values: values_from_layer(layer), + changed, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphStructureChange { + pub structure: GlyphStructure, + pub values: GlyphValues, + pub changed: GlyphChangedEntities, +} + +impl GlyphStructureChange { + pub fn from_layer(layer: &GlyphLayer, changed: GlyphChangedEntities) -> Self { + Self { + structure: GlyphStructure::from(layer), + values: values_from_layer(layer), + changed, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Location { + pub values: HashMap, +} + +impl From<&IrLocation> for Location { + fn from(location: &IrLocation) -> Self { + Self { + values: location + .iter() + .map(|(tag, value)| (tag.clone(), *value)) + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AxisTent { + pub axis_tag: String, + pub lower: f64, + pub peak: f64, + pub upper: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphVariationData { + /// One entry per region. Inner = tents on the axes the region depends on. + pub regions: Vec>, + /// Deltas are flattened in `GlyphState::values` order. + pub deltas: Vec>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphMaster { + pub source_id: String, + pub source_name: String, + pub is_default_source: bool, + pub location: Location, + pub structure: GlyphStructure, + pub values: GlyphValues, +} + +/// Flatten mutable numeric glyph state in the order described by `GlyphState::values`. +pub fn values_from_layer(layer: &GlyphLayer) -> GlyphValues { + let mut values = Vec::new(); + values.push(layer.width()); + + for contour in layer.contours_iter() { + for point in contour.points() { + values.push(point.x()); + values.push(point.y()); + } + } + + for anchor in layer.anchors_iter() { + values.push(anchor.x()); + values.push(anchor.y()); + } + + for component in sorted_components(layer) { + push_transform_values(&mut values, component.transform()); + } + + values +} + +/// Components live in a `HashMap` in the IR, but the values array needs stable +/// ordering. Sort by component ID everywhere structure and values are exported. +fn sorted_components(layer: &GlyphLayer) -> Vec<&IrComponent> { + let mut components: Vec<_> = layer.components_iter().collect(); + components.sort_by_key(|component| component.id().raw()); + components +} + +fn push_transform_values(values: &mut Vec, transform: &IrTransform) { + values.push(transform.translate_x); + values.push(transform.translate_y); + values.push(transform.rotation); + values.push(transform.scale_x); + values.push(transform.scale_y); + values.push(transform.skew_x); + values.push(transform.skew_y); + values.push(transform.t_center_x); + values.push(transform.t_center_y); +} diff --git a/scripts/check-napi-dead-methods.sh b/scripts/check-napi-dead-methods.sh index a8ad1a59..2eb29c5f 100755 --- a/scripts/check-napi-dead-methods.sh +++ b/scripts/check-napi-dead-methods.sh @@ -1,14 +1,14 @@ #!/bin/bash # check-napi-dead-methods.sh — finds Rust NAPI methods with no TypeScript caller. # -# Compares #[napi] methods in font_engine.rs against actual usage in NativeBridge.ts. +# Compares #[napi] methods in bridge.rs against actual usage in TypeScript. # Run: ./scripts/check-napi-dead-methods.sh set -euo pipefail -RUST_FILE="crates/shift-node/src/font_engine.rs" -TS_FILE="apps/desktop/src/renderer/src/bridge/NativeBridge.ts" -NATIVE_TESTS="crates/shift-node/__test__" +RUST_FILE="crates/shift-bridge/src/bridge.rs" +TS_ROOTS=("apps/desktop/src" "packages/bridge/src") +NATIVE_TESTS="crates/shift-bridge/__test__" # Extract Rust pub fn names (napi methods), convert snake_case to camelCase via python rust_methods=$(grep -E '^\s+pub fn ' "$RUST_FILE" | sed 's/.*pub fn //' | sed 's/(.*//' | grep -v '^new$') @@ -22,7 +22,7 @@ parts = s.split('_') print(parts[0] + ''.join(p.capitalize() for p in parts[1:])) ") - if ! grep -rq "\.${camel}\b" "$TS_FILE" "$NATIVE_TESTS" 2>/dev/null; then + if ! grep -rq "\.${camel}\b" "${TS_ROOTS[@]}" "$NATIVE_TESTS" 2>/dev/null; then dead+=("$method → $camel") fi done @@ -37,5 +37,5 @@ for m in "${dead[@]}"; do echo " - $m" done echo "" -echo "Either delete from $RUST_FILE or add callers in $TS_FILE." +echo "Either delete from $RUST_FILE or add TypeScript callers." exit 1 From df13aaeeaca94861adf12ab2b162decc2d51a691 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 5 May 2026 08:56:38 +0100 Subject: [PATCH 07/13] build: add native bridge package boundary Add @shift/bridge as the TypeScript package boundary for constructing the native bridge, and make @shift/types the generated DTO surface for NAPI bridge types. Replace the old ts-rs generated type tree with bridge declaration generation, wire the package into pnpm/turbo, and expose the bridge through preload under the new Bridge naming. The package includes a CommonJS entry so Electron preload can require it at runtime. This intentionally does not finish renderer model migration; it only establishes how TypeScript imports the native bridge and its DTOs. Verified with pnpm --filter @shift/bridge typecheck and require('@shift/bridge') from apps/desktop. --- apps/desktop/package.json | 3 +- apps/desktop/src/preload/docs/DOCS.md | 79 +-- apps/desktop/src/preload/preload.ts | 18 +- .../renderer/src/bridge/NativeBridge.test.ts | 53 -- .../src/renderer/src/bridge/NativeBridge.ts | 497 ------------------ .../src/renderer/src/bridge/docs/DOCS.md | 119 ----- .../desktop/src/renderer/src/bridge/errors.ts | 29 - apps/desktop/src/renderer/src/bridge/index.ts | 9 - .../desktop/src/renderer/src/bridge/native.ts | 15 - apps/desktop/src/shared/bridge/BridgeApi.ts | 9 + .../src/shared/bridge/FontEngineAPI.ts | 30 -- apps/desktop/src/shared/bridge/docs/DOCS.md | 102 +--- package.json | 13 +- packages/bridge/index.cjs | 9 + packages/bridge/package.json | 26 + packages/bridge/src/index.ts | 10 + packages/bridge/tsconfig.json | 8 + packages/types/docs/DOCS.md | 166 +++--- packages/types/package.json | 5 +- packages/types/src/bridge/generated.ts | 214 ++++++++ packages/types/src/bridge/index.ts | 25 + packages/types/src/domain.ts | 101 ---- packages/types/src/font.ts | 43 -- .../types/src/generated/AnchorSnapshot.ts | 5 - packages/types/src/generated/Axis.ts | 3 - packages/types/src/generated/AxisTent.ts | 3 - packages/types/src/generated/CommandResult.ts | 6 - packages/types/src/generated/Component.ts | 4 - .../types/src/generated/ContourSnapshot.ts | 6 - .../src/generated/DecomposedTransform.ts | 7 - packages/types/src/generated/FontMetadata.ts | 3 - packages/types/src/generated/FontMetrics.ts | 3 - packages/types/src/generated/GlyphData.ts | 6 - packages/types/src/generated/GlyphGeometry.ts | 5 - packages/types/src/generated/GlyphSnapshot.ts | 8 - .../types/src/generated/GlyphVariationData.ts | 13 - .../src/generated/InterpolationResult.ts | 5 - packages/types/src/generated/Location.ts | 3 - .../types/src/generated/MasterSnapshot.ts | 5 - packages/types/src/generated/MatchedRule.ts | 10 - packages/types/src/generated/PointSnapshot.ts | 6 - packages/types/src/generated/PointType.ts | 3 - .../src/generated/RenderContourSnapshot.ts | 4 - .../src/generated/RenderPointSnapshot.ts | 4 - packages/types/src/generated/RuleId.ts | 8 - packages/types/src/generated/Source.ts | 4 - packages/types/src/generated/SourceError.ts | 3 - packages/types/src/generated/index.ts | 28 - packages/types/src/ids.ts | 92 ++++ packages/types/src/index.ts | 83 ++- packages/types/src/math.ts | 51 -- pnpm-lock.yaml | 43 +- pnpm-workspace.yaml | 2 +- scripts/generate-bridge-types.mjs | 88 ++++ scripts/patch-generated-types.ts | 153 ------ scripts/watch.sh | 8 +- turbo.json | 20 +- 57 files changed, 689 insertions(+), 1589 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts delete mode 100644 apps/desktop/src/renderer/src/bridge/NativeBridge.ts delete mode 100644 apps/desktop/src/renderer/src/bridge/docs/DOCS.md delete mode 100644 apps/desktop/src/renderer/src/bridge/errors.ts delete mode 100644 apps/desktop/src/renderer/src/bridge/index.ts delete mode 100644 apps/desktop/src/renderer/src/bridge/native.ts create mode 100644 apps/desktop/src/shared/bridge/BridgeApi.ts delete mode 100644 apps/desktop/src/shared/bridge/FontEngineAPI.ts create mode 100644 packages/bridge/index.cjs create mode 100644 packages/bridge/package.json create mode 100644 packages/bridge/src/index.ts create mode 100644 packages/bridge/tsconfig.json create mode 100644 packages/types/src/bridge/generated.ts create mode 100644 packages/types/src/bridge/index.ts delete mode 100644 packages/types/src/domain.ts delete mode 100644 packages/types/src/font.ts delete mode 100644 packages/types/src/generated/AnchorSnapshot.ts delete mode 100644 packages/types/src/generated/Axis.ts delete mode 100644 packages/types/src/generated/AxisTent.ts delete mode 100644 packages/types/src/generated/CommandResult.ts delete mode 100644 packages/types/src/generated/Component.ts delete mode 100644 packages/types/src/generated/ContourSnapshot.ts delete mode 100644 packages/types/src/generated/DecomposedTransform.ts delete mode 100644 packages/types/src/generated/FontMetadata.ts delete mode 100644 packages/types/src/generated/FontMetrics.ts delete mode 100644 packages/types/src/generated/GlyphData.ts delete mode 100644 packages/types/src/generated/GlyphGeometry.ts delete mode 100644 packages/types/src/generated/GlyphSnapshot.ts delete mode 100644 packages/types/src/generated/GlyphVariationData.ts delete mode 100644 packages/types/src/generated/InterpolationResult.ts delete mode 100644 packages/types/src/generated/Location.ts delete mode 100644 packages/types/src/generated/MasterSnapshot.ts delete mode 100644 packages/types/src/generated/MatchedRule.ts delete mode 100644 packages/types/src/generated/PointSnapshot.ts delete mode 100644 packages/types/src/generated/PointType.ts delete mode 100644 packages/types/src/generated/RenderContourSnapshot.ts delete mode 100644 packages/types/src/generated/RenderPointSnapshot.ts delete mode 100644 packages/types/src/generated/RuleId.ts delete mode 100644 packages/types/src/generated/Source.ts delete mode 100644 packages/types/src/generated/SourceError.ts delete mode 100644 packages/types/src/generated/index.ts delete mode 100644 packages/types/src/math.ts create mode 100644 scripts/generate-bridge-types.mjs delete mode 100644 scripts/patch-generated-types.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0a0eb627..1da262ff 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", + "@shift/bridge": "workspace:*", "@shift/font": "workspace:*", "@shift/geo": "workspace:*", "@shift/glyph-info": "workspace:*", @@ -70,7 +71,7 @@ "react-dom": "^19.1.0", "react-router-dom": "^7.12.0", "regl": "^2.1.1", - "shift-node": "workspace:*", + "shift-bridge": "workspace:*", "tailwind-merge": "^3.3.1", "throttle-debounce": "^5.0.2", "zustand": "^5.0.6" diff --git a/apps/desktop/src/preload/docs/DOCS.md b/apps/desktop/src/preload/docs/DOCS.md index f91bf794..34fd3a58 100644 --- a/apps/desktop/src/preload/docs/DOCS.md +++ b/apps/desktop/src/preload/docs/DOCS.md @@ -1,86 +1,53 @@ # Preload -Electron preload script that bridges the native Rust `FontEngine` and typed IPC channels to the renderer via `contextBridge`. +Electron preload script that exposes the native Rust bridge and typed IPC channels to the renderer through `contextBridge`. ## Architecture Invariants -- **Architecture Invariant:** `sandbox: false` is required in `WindowManager.create` `webPreferences` because the preload uses `require("shift-node")` to load the native NAPI addon. **CRITICAL**: enabling the sandbox silently breaks font engine access with no error at bridge creation time -- it only fails when the renderer calls a method. - -- **Architecture Invariant:** `buildBridgeAPI` dynamically wraps every prototype method of the `FontEngine` instance. This means the exposed API surface automatically tracks whatever `#[napi]` methods exist on the Rust side -- no manual method listing required. **CRITICAL**: if a native method is removed from Rust, the preload will still expose a wrapper that throws at call time, not at startup. - -- **Architecture Invariant:** Two separate `contextBridge.exposeInMainWorld` calls create two non-overlapping namespaces: `window.shiftFont` (native font engine) and `window.electronAPI` (IPC + system). These must stay separate because `shiftFont` is synchronous NAPI calls while `electronAPI` is async IPC. - -- **Architecture Invariant:** The `electronAPI` object must satisfy the `ElectronAPI` interface exactly. TypeScript enforces this at compile time. Adding a new IPC channel requires updating `IpcEvents` or `IpcCommands` first, then wiring it here. +- **Architecture Invariant:** The native bridge is created through `@shift/bridge`, not by importing the raw `shift-bridge` NAPI package here. **WHY:** native loading and native-module typing stay in one package boundary. +- **Architecture Invariant:** `buildContextBridgeApi` flattens prototype methods into a plain object before exposing them. **WHY:** `contextBridge` does not preserve class prototype semantics across the isolated context boundary. +- **Architecture Invariant:** Two separate globals are exposed: `window.shiftBridge` for Rust bridge calls and `window.electronAPI` for IPC/system access. **WHY:** native bridge calls and Electron IPC have different lifecycles and failure modes. +- **Architecture Invariant:** The `electronAPI` object must satisfy the `ElectronAPI` interface exactly. **WHY:** adding IPC channels should fail at typecheck time unless preload wiring is updated. ## Codemap ``` preload/ - preload.ts -- single entry point; creates FontEngine instance, - builds bridge API, wires IPC, exposes both namespaces + preload.ts -- creates BridgeApi, flattens it for contextBridge, wires IPC globals ``` ## Key Types -- `FontEngineAPI` -- derived as `Omit`, defined in the bridge module. Single source of truth for the native API surface. -- `ElectronAPI` -- typed interface for all IPC commands, event listeners, system utilities, and clipboard access. Defined in the ipc module. -- `IpcEvents` -- main-to-renderer broadcast channel map (menu actions, theme, debug). -- `IpcCommands` -- renderer-to-main request/response channel map (dialogs, window control, document state). - -## How it works - -The preload runs once before the renderer loads. It does three things: - -1. **Native font engine bridge.** Creates a single `FontEngine` instance from `shift-node`. `buildBridgeAPI` walks the prototype, wrapping each method in a forwarding closure so `contextBridge` can serialize/deserialize arguments correctly. The result is exposed as `window.shiftFont`. - -2. **Typed IPC bridge.** Uses the `listener` and `command` helpers from the ipc module to create typed wrappers around `ipcRenderer.on` and `ipcRenderer.invoke`. Each IPC channel is wired by name to a property on the `electronAPI` object, then exposed as `window.electronAPI`. +- `BridgeApi` -- native bridge API generated from Rust declarations and exposed by `@shift/bridge`. +- `ElectronAPI` -- typed interface for IPC commands, event listeners, system utilities, and clipboard access. +- `IpcEvents` -- main-to-renderer broadcast channel map. +- `IpcCommands` -- renderer-to-main request/response channel map. -3. **Direct system access.** `homePath` is captured once from `os.homedir()`. Clipboard read/write goes through Electron's `clipboard` module directly (no IPC round-trip). +## How It Works -The renderer accesses the font engine through `getNative()` in the bridge module, which caches `window.shiftFont`. All mutation calls go through `NativeBridge`, which wraps `getNative()` with session management, snapshot parsing, and reactive state. +The preload runs once before the renderer loads: -## Workflow recipes - -### Add a new IPC command (renderer-to-main) - -1. Add the channel signature to `IpcCommands` in the ipc channels module. -2. Add the corresponding property to `ElectronAPI` using the `CommandInvoker` type. -3. Add `yourCommand: invoke("your:channel")` to the `electronAPI` object in `preload.ts`. -4. Handle the channel in the main process with `ipcMain.handle`. - -### Add a new IPC event (main-to-renderer) - -1. Add the channel signature to `IpcEvents` in the ipc channels module. -2. Add the corresponding property to `ElectronAPI` using the `EventListener` type. -3. Add `onYourEvent: on("your:channel")` to the `electronAPI` object in `preload.ts`. -4. Send from main process with `webContents.send`. - -### New native FontEngine method appears after Rust rebuild - -Nothing to do in the preload. `buildBridgeAPI` auto-discovers prototype methods. The `FontEngineAPI` type updates automatically since it derives from the napi-generated `FontEngine` class. +1. Calls `createBridge()` from `@shift/bridge`. +2. Converts the bridge class instance into a plain method object with `buildContextBridgeApi`. +3. Exposes that object as `window.shiftBridge`. +4. Builds typed IPC helpers and exposes them as `window.electronAPI`. ## Gotchas -- `buildBridgeAPI` only wraps own prototype methods (skips `constructor` and non-function properties). If a native method is defined as an own property rather than on the prototype, it will not be exposed. -- `contextBridge` serialization means you cannot pass functions, Promises, or class instances through `window.shiftFont` -- only plain data. The native methods already return strings/numbers/booleans so this works, but keep it in mind if extending. -- The `listener` helper returns an unsubscribe function. If the renderer does not call it, the listener leaks. This is the renderer's responsibility, not the preload's. +- `buildContextBridgeApi` only wraps prototype methods. If a native method is added as an own property, it will not be exposed. +- `contextBridge` values must be plain data/functions. Do not expose the native class instance directly. +- `window.shiftBridge` is the raw bridge boundary. Editor/reactive behavior belongs in renderer-side model code, not preload. ## Verification ```bash -# Type-check (catches mismatches between ElectronAPI interface and preload wiring) pnpm --filter @shift/desktop typecheck - -# Lint pnpm --filter @shift/desktop lint ``` ## Related -- `FontEngineAPI` (bridge module) -- type definition for the native API surface -- `ElectronAPI` (ipc module) -- type definition for the IPC/system API surface -- `IpcEvents`, `IpcCommands` (ipc channels module) -- channel maps -- `listener`, `command` (ipc preload module) -- typed IPC wrapper factories -- `NativeBridge` (renderer bridge) -- renderer-side wrapper that consumes `window.shiftFont` -- `getNative` (renderer bridge/native) -- cached accessor for `window.shiftFont` -- `WindowManager` (main module) -- loads the preload script via `webPreferences.preload` +- `@shift/bridge` -- runtime native bridge loader and bridge type exports. +- `@shift/types` -- generated bridge DTO/API facade plus shared primitive DTO types. +- `ElectronAPI` -- IPC/system API surface exposed as `window.electronAPI`. +- `WindowManager` -- loads this preload script through `webPreferences.preload`. diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7f991668..f55abdde 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2,29 +2,29 @@ // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts const { contextBridge, ipcRenderer, clipboard } = require("electron"); -const { FontEngine } = require("shift-node"); const os = require("os"); -import type { FontEngineAPI } from "../shared/bridge/FontEngineAPI"; +import { createBridge, type BridgeApi } from "@shift/bridge"; import type { IpcEvents, IpcCommands } from "../shared/ipc/channels"; import type { ElectronAPI } from "../shared/ipc/electronAPI"; import { listener, command } from "../shared/ipc/preload"; -const fontEngineInstance = new FontEngine(); +const bridge = createBridge(); -function buildBridgeAPI(instance: Record): T { +function buildContextBridgeApi(instance: T): T { const api: Record = {}; + const target = instance as Record; const proto = Object.getPrototypeOf(instance); for (const name of Object.getOwnPropertyNames(proto)) { - if (name === "constructor" || typeof instance[name] !== "function") continue; - api[name] = (...args: unknown[]) => (instance[name] as Function)(...args); + if (name === "constructor" || typeof target[name] !== "function") continue; + api[name] = (...args: unknown[]) => (target[name] as Function)(...args); } - return api as unknown as T; + return api as T; } -const fontEngineAPI = buildBridgeAPI(fontEngineInstance); +const bridgeApi = buildContextBridgeApi(bridge); // Expose to renderer via contextBridge -contextBridge.exposeInMainWorld("shiftFont", fontEngineAPI); +contextBridge.exposeInMainWorld("shiftBridge", bridgeApi); const on = (ch: K) => listener(ipcRenderer, ch); const invoke = (ch: K) => command(ipcRenderer, ch); diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts deleted file mode 100644 index 71502bc1..00000000 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { NativeBridge } from "./NativeBridge"; -import { createBridge } from "@/testing/engine"; - -describe("NativeBridge session lifecycle", () => { - let bridge: NativeBridge; - - beforeEach(() => { - bridge = createBridge(); - }); - - it("has no session and a null $glyph before any start", () => { - expect(bridge.hasSession()).toBe(false); - expect(bridge.$glyph.peek()).toBe(null); - }); - - it("startEditSession opens a session and populates $glyph", () => { - bridge.startEditSession({ glyphName: "A" }); - - expect(bridge.hasSession()).toBe(true); - expect(bridge.$glyph.peek()).not.toBe(null); - expect(bridge.getEditingGlyphName()).toBe("A"); - }); - - it("endEditSession clears the session and nulls $glyph", () => { - bridge.startEditSession({ glyphName: "A" }); - bridge.endEditSession(); - - expect(bridge.hasSession()).toBe(false); - expect(bridge.$glyph.peek()).toBe(null); - }); - - it("starting the same glyph again is a no-op — $glyph reference is preserved", () => { - bridge.startEditSession({ glyphName: "A" }); - const first = bridge.$glyph.peek(); - - bridge.startEditSession({ glyphName: "A" }); - const second = bridge.$glyph.peek(); - - expect(second).toBe(first); - }); - - it("switching to a different glyph replaces the Glyph instance", () => { - bridge.startEditSession({ glyphName: "A" }); - const first = bridge.$glyph.peek(); - - bridge.startEditSession({ glyphName: "B" }); - const second = bridge.$glyph.peek(); - - expect(bridge.getEditingGlyphName()).toBe("B"); - expect(second).not.toBe(first); - }); -}); diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts deleted file mode 100644 index 9b089f83..00000000 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ /dev/null @@ -1,497 +0,0 @@ -import type { - GlyphSnapshot, - FontMetadata, - FontMetrics, - PointId, - ContourId, - Point2D, - AnchorId, - Axis, - AxisLocation, - Source, - GlyphData, - GlyphVariationData, - MasterSnapshot, -} from "@shift/types"; -type RawSourceLocation = { values: { [tag in string]?: number } }; -type RawSource = Omit & { location: RawSourceLocation }; -import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; -import type { Bounds } from "@shift/geo"; -import { Bounds as BoundsUtil } from "@shift/geo"; -import { getNative } from "./native"; -import { NoEditSessionError, NativeOperationError } from "./errors"; -import { constrainDrag } from "@shift/rules"; -import { ValidateSnapshot } from "@shift/validation"; -import { Glyphs } from "@shift/font"; -import type { FontEngineAPI, GlyphHandle } from "@shared/bridge/FontEngineAPI"; -import type { CompositeComponents } from "@shared/bridge/FontEngineAPI"; -import type { CommandResult, PasteResult, PointEdit } from "@/types/engine"; -import { ContourContent } from "@/lib/clipboard"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; -import { Glyph, type GlyphChange } from "@/lib/model/Glyph"; - -export interface InterpolationResult { - instance: GlyphSnapshot; - errors: Array<{ sourceIndex: number; sourceName: string; message: string }>; -} - -/** - * Owns the raw NAPI bridge and the reactive {@link $glyph} signal. - * All font queries, session lifecycle, and glyph mutations live here. - * - * The $glyph signal holds a reactive {@link Glyph} with per-contour signals. - * All mutations go through {@link Glyph.apply} — structural edits pass a - * snapshot, position updates pass a NodePositionUpdateList. - */ -export class NativeBridge { - readonly #$glyph: WritableSignal; - #raw: FontEngineAPI; - - constructor(raw?: FontEngineAPI) { - this.#raw = raw ?? getNative(); - this.#$glyph = signal(null, { equals: () => false }); - } - - get $glyph(): Signal { - return this.#$glyph; - } - - getEditingSnapshot(): GlyphSnapshot | null { - const glyph = this.#$glyph.peek(); - return glyph ? glyph.toSnapshot() : null; - } - - hasSession(): boolean { - return this.#raw.hasEditSession(); - } - - startEditSession(handle: GlyphHandle): void { - if (this.hasSession()) { - const currentName = this.getEditingGlyphName(); - if (currentName === handle.glyphName) return; - this.endEditSession(); - } - this.#raw.startEditSession(handle); - this.#$glyph.set(this.hasSession() ? new Glyph(this) : null); - } - - endEditSession(): void { - this.#raw.endEditSession(); - this.#$glyph.set(null); - } - - getEditingUnicode(): number | null { - return this.#raw.getEditingUnicode(); - } - - getEditingGlyphName(): string | null { - return this.#raw.getEditingGlyphName(); - } - - loadFont(path: string): void { - this.#raw.loadFont(path); - } - - saveFontAsync(path: string): Promise { - return this.#raw.saveFontAsync(path); - } - - /** @knipclassignore — satisfies Font interface */ - getMetadata(): FontMetadata { - return JSON.parse(this.#raw.getMetadata()); - } - - getMetrics(): FontMetrics { - return JSON.parse(this.#raw.getMetrics()); - } - - getGlyphUnicodes(): number[] { - return this.#raw.getGlyphUnicodes(); - } - - nameForUnicode(unicode: number): string | null { - return this.#raw.getGlyphNameForUnicode(unicode); - } - - /** @knipclassignore — used by glyph grid for dependent lookups */ - getDependentUnicodesByName(glyphName: string): number[] { - return this.#raw.getDependentUnicodesByName(glyphName); - } - - getSvgPath(name: string): string | null { - return this.#raw.getGlyphSvgPathByName(name) ?? null; - } - - /** @knipclassignore — satisfies Font interface */ - getAdvance(name: string): number | null { - return this.#raw.getGlyphAdvanceByName(name) ?? null; - } - - getBbox(name: string): Bounds | null { - const b = this.#raw.getGlyphBboxByName(name); - if (b == null || b.length !== 4) return null; - return BoundsUtil.create({ x: b[0], y: b[1] }, { x: b[2], y: b[3] }); - } - - /** @knipclassignore — satisfies Font interface */ - getPath(name: string): Path2D | null { - const editing = this.#$glyph.peek(); - if (editing?.name === name) return editing.path; - const svg = this.getSvgPath(name); - return svg ? new Path2D(svg) : null; - } - - getGlyphCompositeComponents(glyphName: string): CompositeComponents | null { - const payload = this.#raw.getGlyphCompositeComponents(glyphName); - if (!payload) return null; - return JSON.parse(payload) as CompositeComponents; - } - - /** - * Bundled per-glyph fetch for the render-side `GlyphView` model. One FFI - * returns geometry, variation deltas, and component refs (names + transforms, - * not pre-flattened — the renderer recurses into composites at iteration time). - */ - getGlyphData(name: string): GlyphData | null { - const json = this.#raw.getGlyphData(name); - if (!json) return null; - return JSON.parse(json) as GlyphData; - } - - /** @knipclassignore — used by VariationPanel component */ - isVariable(): boolean { - return this.#raw.isVariable(); - } - - /** @knipclassignore — used by VariationPanel component */ - getAxes(): Axis[] { - return JSON.parse(this.#raw.getAxes()) as Axis[]; - } - - /** @knipclassignore — used by VariationPanel component */ - getSources(): Source[] { - const raw = JSON.parse(this.#raw.getSources()) as RawSource[]; - const axes = this.getAxes(); - return raw.map((source) => ({ - ...source, - location: resolveAxisLocation(source.location.values, axes), - })); - } - - /** @knipclassignore — used by VariationPanel component */ - getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { - const json = this.#raw.getGlyphMasterSnapshots(glyphName); - if (!json) return null; - return JSON.parse(json) as MasterSnapshot[]; - } - - getGlyphVariationData(glyphName: string): GlyphVariationData | null { - const json = this.#raw.getGlyphVariationData(glyphName); - if (!json) return null; - return JSON.parse(json) as GlyphVariationData; - } - - getSnapshot(): GlyphSnapshot { - return JSON.parse(this.#raw.getSnapshotData()) as GlyphSnapshot; - } - - #execute(json: string): CommandResult { - const raw = JSON.parse(json); - if (!raw.success) { - throw new NativeOperationError(raw.error ?? "Unknown native error"); - } - if (!raw.snapshot) { - throw new NativeOperationError("Native operation succeeded but returned no snapshot"); - } - return { snapshot: raw.snapshot as GlyphSnapshot, affectedPointIds: raw.affectedPointIds }; - } - - #dispatch(json: string): PointId[] { - this.#requireSession(); - const response = this.#execute(json); - this.#syncFromResponse(response.snapshot); - return response.affectedPointIds; - } - - #dispatchVoid(json: string): void { - this.#requireSession(); - const response = this.#execute(json); - this.#syncFromResponse(response.snapshot); - } - - #syncFromResponse(snapshot: GlyphSnapshot): void { - const glyph = this.#$glyph.peek(); - if (glyph) { - glyph.apply(snapshot); - this.#$glyph.set(glyph); - } else { - this.#$glyph.set(new Glyph(this)); - } - } - - #requireSession(): void { - if (!this.hasSession()) { - throw new NoEditSessionError(); - } - } - - addPoint(edit: PointEdit): PointId { - const ids = this.#dispatch(this.#raw.addPoint(edit.x, edit.y, edit.pointType, edit.smooth)); - const pointId = ids[0]; - if (pointId) return pointId; - - const glyph = this.#$glyph.peek(); - if (!glyph) throw new NativeOperationError("Native addPoint returned no point ID"); - const lastContour = glyph.contours[glyph.contours.length - 1]; - const lastPoint = lastContour?.points[lastContour.points.length - 1]; - if (!lastPoint) { - throw new NativeOperationError("Native addPoint returned no point ID"); - } - return lastPoint.id; - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - addPointToContour(contourId: ContourId, edit: PointEdit): PointId { - const ids = this.#dispatch( - this.#raw.addPointToContour(contourId, edit.x, edit.y, edit.pointType, edit.smooth), - ); - const pointId = ids[0]; - if (pointId) return pointId; - throw new NativeOperationError("Native addPointToContour returned no point ID"); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - insertPointBefore(beforePointId: PointId, edit: PointEdit): PointId { - const ids = this.#dispatch( - this.#raw.insertPointBefore(beforePointId, edit.x, edit.y, edit.pointType, edit.smooth), - ); - const pointId = ids[0]; - if (pointId) return pointId; - throw new NativeOperationError("Native insertPointBefore returned no point ID"); - } - - movePoints(pointIds: PointId[], delta: Point2D): PointId[] { - if (pointIds.length === 0) return []; - return this.#dispatch( - this.#raw.moveNodes( - pointIds.map((id) => ({ kind: "point", id })), - delta.x, - delta.y, - ), - ); - } - - moveAnchors(anchorIds: AnchorId[], delta: Point2D): void { - if (anchorIds.length === 0) return; - this.#dispatchVoid( - this.#raw.moveNodes( - anchorIds.map((id) => ({ kind: "anchor", id })), - delta.x, - delta.y, - ), - ); - } - - movePointTo(pointId: PointId, x: number, y: number): void { - this.#requireSession(); - - const snapshot = this.getEditingSnapshot(); - if (!snapshot) throw new NativeOperationError("No glyph available"); - - const found = Glyphs.findPoint(snapshot, pointId); - if (!found) throw new NativeOperationError(`Point ${pointId} not found`); - - this.movePoints([pointId], { x: x - found.point.x, y: y - found.point.y }); - } - - removePoints(pointIds: PointId[]): void { - if (pointIds.length === 0) return; - this.#dispatchVoid(this.#raw.removePoints(pointIds)); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - toggleSmooth(pointId: PointId): void { - this.#dispatchVoid(this.#raw.toggleSmooth(pointId)); - } - - addContour(): ContourId { - this.#requireSession(); - const response = this.#execute(this.#raw.addContour()); - this.#syncFromResponse(response.snapshot); - return response.snapshot.activeContourId!; - } - - closeContour(): void { - this.#dispatchVoid(this.#raw.closeContour()); - } - - getActiveContourId(): ContourId | null { - if (!this.hasSession()) return null; - return this.#raw.getActiveContourId(); - } - - setActiveContour(contourId: ContourId): void { - this.#dispatchVoid(this.#raw.setActiveContour(contourId)); - } - - clearActiveContour(): void { - if (!this.hasSession()) return; - this.#dispatchVoid(this.#raw.clearActiveContour()); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - reverseContour(contourId: ContourId): void { - this.#dispatchVoid(this.#raw.reverseContour(contourId)); - } - - /** @knipclassignore — used via Editor delegation */ - applyBooleanOp( - contourIdA: ContourId, - contourIdB: ContourId, - operation: "union" | "subtract" | "intersect" | "difference", - ): void { - this.#dispatchVoid(this.#raw.applyBooleanOp(contourIdA, contourIdB, operation)); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - openContour(contourId: ContourId): void { - this.#dispatchVoid(this.#raw.openContour(contourId)); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - setXAdvance(width: number): void { - this.#dispatchVoid(this.#raw.setXAdvance(width)); - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - translateLayer(dx: number, dy: number): void { - this.#dispatchVoid(this.#raw.translateLayer(dx, dy)); - } - - setNodePositions(updates: NodePositionUpdateList): void { - if (!this.hasSession()) return; - if (updates.length === 0) return; - - const glyph = this.#$glyph.peek(); - if (!glyph) return; - - glyph.apply(updates); - this.#$glyph.set(glyph); - this.#syncPositions(updates); - } - - /** @knipclassignore — used by Editor for smart drag constraints */ - applySmartEdits(selectedPoints: ReadonlySet, dx: number, dy: number): PointId[] { - if (!this.hasSession()) return []; - const reactive = this.#$glyph.peek(); - if (!reactive) return []; - const glyph = reactive.toSnapshot(); - - const patch = constrainDrag( - { glyph, selectedIds: selectedPoints, mousePosition: { x: dx, y: dy } }, - { includeMatchedRules: false }, - ); - - const updates: NodePositionUpdateList = patch.pointUpdates.map( - (update: (typeof patch.pointUpdates)[number]) => ({ - node: { kind: "point", id: update.id }, - x: update.x, - y: update.y, - }), - ); - if (updates.length > 0) { - this.setNodePositions(updates); - } - return patch.pointUpdates.map((u: (typeof patch.pointUpdates)[number]) => u.id); - } - - pasteContours(contours: ContourContent[], offsetX: number, offsetY: number): PasteResult { - this.#requireSession(); - const contoursJson = JSON.stringify(contours); - const raw = JSON.parse(this.#raw.pasteContours(contoursJson, offsetX, offsetY)); - - if (!raw.success) { - throw new NativeOperationError(raw.error ?? "pasteContours failed"); - } - - const snapshot = this.getSnapshot(); - this.#syncFromResponse(snapshot); - - return { - success: raw.success, - createdPointIds: raw.createdPointIds, - createdContourIds: raw.createdContourIds, - error: raw.error, - }; - } - - /** @knipclassignore — used via Editor delegation or Glyph */ - restoreSnapshot(snapshot: GlyphSnapshot): void { - this.#requireSession(); - if (!ValidateSnapshot.isGlyphSnapshot(snapshot)) { - throw new NativeOperationError("Cannot restore invalid snapshot"); - } - const success = this.#raw.restoreSnapshot(JSON.stringify(snapshot)); - if (!success) { - throw new NativeOperationError("Failed to restore snapshot"); - } - this.#syncFromResponse(snapshot); - } - - /** - * Rust-side mirror of glyph.apply(). Syncs a change to Rust without - * updating the JS reactive model. Position updates use Float64Array - * (zero-copy), snapshots use JSON. - */ - sync(change: GlyphChange): void { - this.#requireSession(); - - if (Array.isArray(change)) { - this.#syncPositions(change); - return; - } - - const success = this.#raw.restoreSnapshot(JSON.stringify(change)); - if (!success) { - throw new NativeOperationError("Failed to sync snapshot to native"); - } - } - - #syncPositions(updates: NodePositionUpdateList): void { - if (updates.length === 0) return; - - const pointIds: number[] = []; - const pointCoords: number[] = []; - const anchorIds: number[] = []; - const anchorCoords: number[] = []; - - for (const u of updates) { - switch (u.node.kind) { - case "point": - pointIds.push(Number(u.node.id)); - pointCoords.push(u.x, u.y); - break; - case "anchor": - anchorIds.push(Number(u.node.id)); - anchorCoords.push(u.x, u.y); - break; - } - } - - this.#raw.setPositions( - pointIds.length > 0 ? new Float64Array(pointIds) : null, - pointCoords.length > 0 ? new Float64Array(pointCoords) : null, - anchorIds.length > 0 ? new Float64Array(anchorIds) : null, - anchorCoords.length > 0 ? new Float64Array(anchorCoords) : null, - ); - } -} - -function resolveAxisLocation( - values: RawSourceLocation["values"] | undefined, - axes: Axis[], -): AxisLocation { - const out: AxisLocation = {}; - for (const axis of axes) out[axis.tag] = values?.[axis.tag] ?? axis.default; - return out; -} diff --git a/apps/desktop/src/renderer/src/bridge/docs/DOCS.md b/apps/desktop/src/renderer/src/bridge/docs/DOCS.md deleted file mode 100644 index 1a5826c6..00000000 --- a/apps/desktop/src/renderer/src/bridge/docs/DOCS.md +++ /dev/null @@ -1,119 +0,0 @@ -# NativeBridge - -Reactive wrapper over the Rust NAPI bindings that owns the `Glyph` lifecycle and provides the sole API boundary between the JS editor and the native font engine. - -## Architecture Invariants - -- **Architecture Invariant:** All glyph mutations must go through `Glyph.apply` for the JS-side reactive model. Direct signal writes on `Contour` or `Glyph` internals will bypass the path/bounds recomputation pipeline. -- **Architecture Invariant:** **CRITICAL**: `GlyphDraft.setPositions` must only call `Glyph.apply` -- never `NativeBridge` methods. Crossing the NAPI boundary on every drag frame kills performance. Rust sync happens once on `GlyphDraft.finish`. -- **Architecture Invariant:** **CRITICAL**: `#syncPositions` passes `null` (not a zero-length `Float64Array`) when a category has no updates. napi-rs panics on zero-length `Float64Array`. -- **Architecture Invariant:** `$glyph` is an identity signal (`equals: () => false`). It fires on glyph open/close, not on data changes. Render effects must track `Glyph` internal signals (`contours`, `anchors`, `path`) to respond to mutations. -- **Architecture Invariant:** `Glyph` holds a back-reference to `NativeBridge` so it can delegate structural mutation methods (`addPoint`, `removePoints`, etc.) directly. This keeps callers from needing both a bridge and a glyph reference. -- **Architecture Invariant:** Snapshot restore validates via `ValidateSnapshot.isGlyphSnapshot` before sending to Rust. Invalid snapshots throw `NativeOperationError` without touching native state. - -## Codemap - -``` -bridge/ - NativeBridge.ts — NAPI wrapper, session lifecycle, mutation dispatch - native.ts — cached `window.shiftFont` accessor (FontEngineAPI) - errors.ts — FontEngineError, NoEditSessionError, NativeOperationError - index.ts — re-exports NativeBridge, Glyph, Contour, errors -``` - -## Key Types - -- `NativeBridge` -- owns `#raw` (FontEngineAPI) and `#$glyph` (WritableSignal). All font queries and mutations live here. -- `Glyph` -- reactive mirror of a Rust glyph with per-contour signal granularity. Property getters auto-unwrap signals. All mutations enter through `Glyph.apply`. -- `Contour` -- reactive contour with `#points`, `#closed`, computed `#path` and `#bounds`. Updated via `_update` (snapshot) or `_setPoints` (position patch). -- `GlyphChange` -- union of `GlyphSnapshot | NodePositionUpdateList`. Snapshot for structural edits; update list for position-only changes. -- `GlyphDraft` -- interface with `setPositions`, `finish`, `discard`. Created by `Editor.createDraft`, implements immer-style preview/commit separation. -- `NodePositionUpdateList` -- `readonly NodePositionUpdate[]`, each entry is a `NodeRef` (point, anchor, or guideline) with absolute x/y. -- `FontEngineAPI` -- derived from the napi-rs generated `FontEngine` class, exposed via `window.shiftFont` through Electron's contextBridge. -- `NoEditSessionError` / `NativeOperationError` -- thrown when an operation requires a session or the Rust side fails. - -## How it works - -### Two-tier mutation model - -The bridge separates JS-side reactivity from Rust-side persistence. Not every mutation crosses the NAPI boundary immediately. - -**Tier 1 -- JS-only (`Glyph.apply`):** Updates reactive signals (`#contours`, `#anchors`). Render effects auto-track these signals and schedule redraws. Rust is untouched. Used by `GlyphDraft.setPositions` during drag (every frame). - -**Tier 2 -- Rust sync (`NativeBridge.sync`):** Pushes a `GlyphChange` to Rust without updating the JS model (already correct from Tier 1). Position updates go through `#syncPositions` (flat `Float64Array` arrays); snapshots go through `restoreSnapshot` (JSON). Used by `GlyphDraft.finish` on drag end (once). - -**Combined -- JS + Rust:** Methods like `setNodePositions` and `restoreSnapshot` update both sides atomically. Used by commands (`SetNodePositionsCommand`, `SnapshotCommand`) and sidebar edits where the mutation is immediate. - -### Dispatch pipeline - -`#dispatch` / `#dispatchVoid` are the standard Rust command path for structural mutations (addPoint, removePoints, closeContour, etc.): - -1. Call NAPI method, receive JSON string -2. `#execute` parses JSON, checks `success`, extracts `CommandResult` (snapshot + affectedPointIds) -3. `#syncFromResponse` applies the returned snapshot to the reactive `Glyph` via `Glyph.apply` - -### NAPI position sync - -`#syncPositions` converts `NodePositionUpdateList` into four flat arrays (pointIds, pointCoords, anchorIds, anchorCoords) packed as `Float64Array`, then calls `#raw.setPositions`. Cost is proportional to the number of changed nodes, not the glyph size. - -### GlyphDraft lifecycle - -Created by `Editor.createDraft`. Captures a base snapshot, then: - -- `setPositions(updates)` -- calls `Glyph.apply(updates)` (JS-only, no Rust) -- `finish(label)` -- calls `NativeBridge.sync(updates)` for Rust sync, then records a `SetNodePositionsCommand` for undo (stores the diff, not two full snapshots) -- `discard()` -- calls `Glyph.apply(base)` to revert JS state (Rust was never touched) - -A `finished` flag prevents double-finish or post-finish mutation. - -### Glyph reactive model - -`Glyph` wraps each data dimension in its own signal. Computed signals (`#path`, `#bbox`) derive from these automatically. `Glyph.apply` dispatches on change type: - -- Snapshot: `#syncFromSnapshot` reconciles contours by ID (reuses existing `Contour` instances, creates new ones as needed), updates all scalar signals inside a `batch`. -- Position updates: `#patchPositions` maps point/anchor IDs to new coordinates and patches only the affected `Contour` instances. - -## Workflow recipes - -### Add a new NAPI-backed mutation - -1. Add the `#[napi]` method in Rust, rebuild -- it appears on `FontEngineAPI` automatically -2. Add a public method on `NativeBridge` that calls `#dispatch` or `#dispatchVoid` with the raw result -3. If callers should access it through `Glyph`, add a delegation method on `Glyph` that calls `this.#bridge.` - -### Add a new position-based operation - -1. Build a `NodePositionUpdateList` with the target absolute positions -2. For immediate apply: call `NativeBridge.setNodePositions(updates)` -3. For draft-based (drag): call `GlyphDraft.setPositions(updates)` per frame, then `GlyphDraft.finish(label)` on end - -### Support undo for a structural edit - -Wrap the mutation in a `SnapshotCommand` -- capture a before-snapshot, execute the bridge method, the after-snapshot is the current state. - -## Gotchas - -- `Float64Array` of length 0 panics napi-rs. `#syncPositions` guards this by passing `null` instead. -- `$glyph` has `equals: () => false`, so every `.set()` fires subscribers even if the instance is the same. This is intentional for session open/close detection, but means you should never use `$glyph` as a change-tracking signal for data. -- `Glyph.apply` with a snapshot reconciles contours by ID. If a Rust mutation changes contour IDs (e.g., boolean operations), old `Contour` instances are dropped and new ones created. Effects holding stale `Contour` references will read the old signals. -- `getPath` for the currently-editing glyph returns the reactive `Glyph.path` (computed from JS signals), not a fresh Rust SVG path. This ensures composites render with live edits. -- `#execute` throws `NativeOperationError` if the Rust response has `success: false` or is missing a snapshot. Callers of `#dispatch` do not need to check for errors -- they propagate as exceptions. - -## Verification - -```bash -# Unit tests (draft lifecycle, position sync) -npx vitest run apps/desktop/src/renderer/src/lib/editor/draft.test.ts - -# Type check -npx tsc --noEmit -p apps/desktop/tsconfig.json -``` - -## Related - -- `Editor.createDraft` -- constructs `GlyphDraft` instances and records undo via `SetNodePositionsCommand` -- `SetNodePositionsCommand` / `SnapshotCommand` -- undo/redo primitives that call back into `NativeBridge` -- `FontEngineAPI` -- the NAPI type surface, derived from `shift-node` -- `ValidateSnapshot` -- validates `GlyphSnapshot` shape before Rust restore -- `constrainDrag` -- smart edit rules invoked by `NativeBridge.applySmartEdits` -- `TestEditor` -- test harness that wraps `NativeBridge` with a mock `FontEngineAPI` diff --git a/apps/desktop/src/renderer/src/bridge/errors.ts b/apps/desktop/src/renderer/src/bridge/errors.ts deleted file mode 100644 index a52ced9b..00000000 --- a/apps/desktop/src/renderer/src/bridge/errors.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Custom error class for FontEngine operations. - */ -export class FontEngineError extends Error { - constructor(message: string) { - super(message); - this.name = "FontEngineError"; - } -} - -/** - * Error thrown when an operation requires an active edit session but none exists. - */ -export class NoEditSessionError extends FontEngineError { - constructor() { - super("No active edit session. Call session.start() first."); - this.name = "NoEditSessionError"; - } -} - -/** - * Error thrown when an operation fails on the Rust side. - */ -export class NativeOperationError extends FontEngineError { - constructor(message: string) { - super(message); - this.name = "NativeOperationError"; - } -} diff --git a/apps/desktop/src/renderer/src/bridge/index.ts b/apps/desktop/src/renderer/src/bridge/index.ts deleted file mode 100644 index 24ebe764..00000000 --- a/apps/desktop/src/renderer/src/bridge/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { NativeBridge } from "./NativeBridge"; -export type { FontMetadata, FontMetrics } from "@shift/types"; - -export { FontEngineError, NoEditSessionError, NativeOperationError } from "./errors"; - -export type { FontEngineAPI } from "./native"; - -export type { GlyphDraft } from "@/types/draft"; -export { Glyph, Contour } from "@/lib/model/Glyph"; diff --git a/apps/desktop/src/renderer/src/bridge/native.ts b/apps/desktop/src/renderer/src/bridge/native.ts deleted file mode 100644 index 64719494..00000000 --- a/apps/desktop/src/renderer/src/bridge/native.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FontEngineAPI } from "@shared/bridge/FontEngineAPI"; - -export type { FontEngineAPI }; -export type NativeFontEngine = FontEngineAPI; - -let cached: FontEngineAPI | null = null; - -export function getNative(): FontEngineAPI { - if (cached) return cached; - if (!window.shiftFont) { - throw new Error("Native FontEngine not available"); - } - cached = window.shiftFont; - return cached; -} diff --git a/apps/desktop/src/shared/bridge/BridgeApi.ts b/apps/desktop/src/shared/bridge/BridgeApi.ts new file mode 100644 index 00000000..b0233c93 --- /dev/null +++ b/apps/desktop/src/shared/bridge/BridgeApi.ts @@ -0,0 +1,9 @@ +import type { BridgeApi } from "@shift/bridge"; + +export type { BridgeApi, GlyphHandle } from "@shift/bridge"; + +declare global { + interface Window { + shiftBridge?: BridgeApi; + } +} diff --git a/apps/desktop/src/shared/bridge/FontEngineAPI.ts b/apps/desktop/src/shared/bridge/FontEngineAPI.ts deleted file mode 100644 index 234d0fe2..00000000 --- a/apps/desktop/src/shared/bridge/FontEngineAPI.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Derived from napi-rs generated FontEngine class — zero maintenance. - * When you add a #[napi] method in Rust and rebuild, it appears here automatically. - */ -import type { FontEngine, GlyphHandle, JsNodeRef, JsNodePositionUpdate } from "shift-node"; -import type { RenderContourSnapshot } from "@shift/types"; - -export type FontEngineAPI = Omit; - -export type { GlyphHandle }; - -export type NodeRef = JsNodeRef; -export type NodePositionUpdate = JsNodePositionUpdate; - -export interface CompositeComponent { - componentGlyphName: string; - sourceUnicodes: number[]; - contours: RenderContourSnapshot[]; -} - -export interface CompositeComponents { - glyphName: string; - components: CompositeComponent[]; -} - -declare global { - interface Window { - shiftFont?: FontEngineAPI; - } -} diff --git a/apps/desktop/src/shared/bridge/docs/DOCS.md b/apps/desktop/src/shared/bridge/docs/DOCS.md index baf9c8df..a3414dd2 100644 --- a/apps/desktop/src/shared/bridge/docs/DOCS.md +++ b/apps/desktop/src/shared/bridge/docs/DOCS.md @@ -1,107 +1,47 @@ # Shared Bridge -Type-safe API boundary between the Rust `FontEngine` (via NAPI) and the TypeScript renderer. +Shared TypeScript declaration point for the preload-to-renderer bridge contract. ## Architecture Invariants -- **Architecture Invariant:** `FontEngineAPI` is derived as `Omit` -- it is never hand-maintained. When a `#[napi]` method is added in Rust and rebuilt, `FontEngineAPI` picks it up automatically. -- **Architecture Invariant:** The `Window.shiftFont` global declaration lives here, making this module the single source of truth for the bridge contract. Both the preload (producer) and renderer (consumer) import from this file. -- **Architecture Invariant: CRITICAL:** `buildBridgeAPI` in the preload reflects _all_ prototype methods of the `FontEngine` instance at runtime. If `FontEngineAPI` drifts from the actual `FontEngine` class (e.g., shift-node is not rebuilt), the TypeScript types will lie -- calls will fail at runtime with no compile-time warning. -- **Architecture Invariant:** `NodeRef` and `NodePositionUpdate` are re-exported aliases for `JsNodeRef` and `JsNodePositionUpdate` from shift-node. Renderer code should import these aliases, not the `Js`-prefixed originals. +- **Architecture Invariant:** `BridgeApi` comes from `@shift/bridge`, which re-exports the generated `@shift/types` DTO API. **WHY:** app code does not depend on the raw NAPI package declaration. +- **Architecture Invariant:** `Window.shiftBridge` is declared here. **WHY:** preload and renderer agree on one global native bridge name. +- **Architecture Invariant:** This shared module is type-only. **WHY:** runtime native loading belongs in `@shift/bridge` and preload wiring belongs in `preload.ts`. ## Codemap ``` shared/bridge/ - FontEngineAPI.ts — derives FontEngineAPI from FontEngine, declares Window.shiftFont, - exports composite payload types and node-ref aliases + BridgeApi.ts -- re-exports bridge types and declares Window.shiftBridge preload/ - preload.ts — buildBridgeAPI reflects FontEngine onto window.shiftFont - -renderer/src/bridge/ - native.ts — getNative() accessor, caches window.shiftFont - NativeBridge.ts — high-level wrapper: reactive $glyph signal, dispatch/sync + preload.ts -- exposes BridgeApi as window.shiftBridge ``` ## Key Types -- **`FontEngineAPI`** -- `Omit`. The complete method surface exposed to the renderer. Derived, not hand-written. -- **`NodeRef`** -- alias for `JsNodeRef`. Tagged union `{ kind: "point" | "anchor" | "guideline", id: string }`. -- **`NodePositionUpdate`** -- alias for `JsNodePositionUpdate`. `{ node: NodeRef, x: number, y: number }`. -- **`CompositeComponent`** / **`CompositeComponents`** -- shapes for composite glyph component data (component glyph name, source unicodes, contours). -- **`Window.shiftFont`** -- global declaration (`FontEngineAPI | undefined`) that both preload and renderer rely on. - -## How it works +- `BridgeApi` -- complete native bridge API surface. +- `GlyphHandle` -- bridge glyph session handle. +- `Window.shiftBridge` -- optional global bridge exposed by preload. -### Data flow: Rust to renderer +## Data Flow ``` -FontEngine (Rust, #[napi]) - → shift-node index.d.ts (generated class + interfaces) - → FontEngineAPI.ts: Omit - → preload.ts: buildBridgeAPI() reflects all methods onto a plain object - → contextBridge.exposeInMainWorld("shiftFont", ...) - → native.ts: getNative() reads window.shiftFont, caches it - → NativeBridge wraps getNative() with reactive state + error handling -``` - -### Why `Omit` instead of `satisfies` - -The old design hand-listed every method in a `FontEngineAPI` interface and used `satisfies` in the preload to catch missing implementations. The current design derives the type directly from `FontEngine` via `Omit`, and the preload uses `buildBridgeAPI` to reflect all prototype methods at runtime. This means: - -1. Zero maintenance when Rust methods change -- no interface to update. -2. No compile-time check that individual methods exist in the preload, because `buildBridgeAPI` copies them all by reflection. -3. The trade-off: correctness now depends on shift-node being rebuilt. If the `.d.ts` is stale, types and runtime diverge silently. - -### `buildBridgeAPI` (preload) - -Generic function that walks `Object.getPrototypeOf(instance)`, skips `constructor`, and wraps each method in a forwarding closure. Returns the result typed as `T` (which is `FontEngineAPI`). This creates the plain object that `contextBridge.exposeInMainWorld` requires (Electron strips prototype chains across the context boundary). - -### `NativeBridge` (renderer) - -The high-level consumer. Holds a `#raw: FontEngineAPI` (from `getNative()`) and a reactive `$glyph` signal. Mutations go through `#dispatch` / `#dispatchVoid`, which call the native method, parse the JSON command response, and sync the reactive glyph model. Position-only updates use `setPositions` with `Float64Array` for zero-copy performance. - -## Workflow recipes - -### Add a new Rust function to the bridge - -1. Add `#[napi]` method to `FontEngine` in shift-node. -2. Run `pnpm dev` (turbo rebuilds native, regenerates `index.d.ts`). -3. Done -- `FontEngineAPI` picks up the new method automatically via `Omit`. -4. Use the method through `getNative().myNewMethod(...)` or wrap it in `NativeBridge`. - -### Add a renderer-only type to the bridge module - -1. Add the type/interface to `FontEngineAPI.ts`. -2. Import from `@shared/bridge/FontEngineAPI` in renderer code. - -### Access native engine from renderer code - -```typescript -import { getNative } from "@/bridge/native"; -const native = getNative(); // throws if window.shiftFont is missing -native.loadFont(path); +shift-bridge raw napi-rs package + -> @shift/bridge createBridge() + -> preload.ts buildContextBridgeApi() + -> contextBridge.exposeInMainWorld("shiftBridge", ...) + -> renderer/editor reactive wrapper ``` -Or use `NativeBridge` for reactive glyph state. - ## Gotchas -- **Stale native build**: If shift-node is not rebuilt after Rust changes, `FontEngineAPI` will match the old `.d.ts` while the actual NAPI binary has new/changed methods. `pnpm dev` handles this via turbo dependency ordering, but manual workflows can miss it. -- **contextBridge strips prototypes**: Electron's `contextBridge` serializes objects across the context boundary. `buildBridgeAPI` exists specifically to flatten methods onto a plain object. Never try to expose the `FontEngine` instance directly. -- **JSON serialization boundary**: Most native methods return JSON strings. `NativeBridge` parses them (`JSON.parse`), but `FontEngineAPI` types show `string` return types, not the parsed shapes. The parsed types (`GlyphSnapshot`, `FontMetrics`, etc.) live in `@shift/types`. -- **`setPositions` null guards**: napi-rs panics on zero-length `Float64Array`. `NativeBridge.#syncPositions` passes `null` instead of empty arrays. - -## Verification - -- `pnpm typecheck` -- confirms `FontEngineAPI` still matches the generated `FontEngine` class. -- `pnpm dev` -- turbo rebuilds native before starting the dev server, ensuring types and binary are in sync. +- `BridgeApi` is a bridge DTO/API contract, not the editor domain model. +- Renderer code should adapt bridge DTOs into editor model state rather than leaking transport names through the app. +- If Rust declarations change, rebuild `shift-bridge` and run `pnpm generate:bridge-types`. ## Related -- **`FontEngine`** (shift-node) -- the NAPI class whose type `FontEngineAPI` derives from. -- **`buildBridgeAPI`** (preload) -- reflects the engine instance for contextBridge. -- **`getNative`** (renderer bridge) -- cached accessor for `window.shiftFont`. -- **`NativeBridge`** (renderer bridge) -- reactive wrapper with `$glyph` signal, dispatch/sync logic. -- **`@shift/types`** -- domain types (`GlyphSnapshot`, `FontMetrics`, `RenderContourSnapshot`) that native JSON responses are parsed into. +- `@shift/bridge` -- runtime bridge package. +- `@shift/types` -- generated bridge DTO/API types plus shared primitive DTO types. +- `preload.ts` -- producer of `window.shiftBridge`. diff --git a/package.json b/package.json index ebb656e0..75fae355 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,21 @@ "dev": "pnpm run build:native:debug && pnpm --filter @shift/desktop dev", "dev:watch": "scripts/watch.sh", "build": "turbo run build", - "build:native": "pnpm --filter shift-node run build", - "build:native:debug": "pnpm --filter shift-node run build:debug", - "generate:types": "cargo test --package shift-core && npx tsx scripts/patch-generated-types.ts", + "build:native": "pnpm --filter shift-bridge run build", + "build:native:debug": "pnpm --filter shift-bridge run build:debug", + "generate:bridge-types": "turbo run generate:bridge-types", + "generate:types": "pnpm generate:bridge-types", "generate:glyph-info": "pnpm --filter @shift/glyph-info generate", "glyph-info:repl": "pnpm --filter @shift/glyph-info repl", "test": "turbo run test", - "test:unit": "pnpm turbo run test --filter='!shift-node'", - "test:integration": "pnpm --filter shift-node run test && cargo test -p shift-core --test font_loading --test round_trip", + "test:unit": "pnpm turbo run test --filter='!shift-bridge'", + "test:integration": "pnpm --filter shift-bridge run test && cargo test -p shift-edit --test font_loading --test round_trip", "test:lint": "pnpm --filter @shift/desktop run lint:check", "test:typecheck": "pnpm typecheck", "test:perf": "pnpm --filter @shift/desktop exec vitest bench --run", "test:playwright": "pnpm --filter @shift/desktop test:e2e", "test:ci": "pnpm test:lint && pnpm format:check && pnpm test:typecheck && pnpm test:unit && pnpm test:native && cargo test --workspace && pnpm deadcode:strict", - "test:native": "pnpm --filter shift-node run test", + "test:native": "pnpm --filter shift-bridge run test", "typecheck": "pnpm run generate:types && turbo run typecheck", "lint": "turbo run lint", "lint:check": "turbo run lint:check", diff --git a/packages/bridge/index.cjs b/packages/bridge/index.cjs new file mode 100644 index 00000000..b6943459 --- /dev/null +++ b/packages/bridge/index.cjs @@ -0,0 +1,9 @@ +const { Bridge } = require("shift-bridge"); + +function createBridge() { + return new Bridge(); +} + +module.exports = { + createBridge, +}; diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 00000000..3aa43190 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,26 @@ +{ + "name": "@shift/bridge", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./index.cjs", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "require": "./index.cjs" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@shift/types": "workspace:*", + "shift-bridge": "workspace:*" + }, + "devDependencies": { + "@shift/tsconfig": "workspace:*", + "typescript": "^5.5.4" + } +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 00000000..be076bd9 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,10 @@ +import { Bridge } from "shift-bridge"; +import type { BridgeApi } from "@shift/types"; + +export type * from "@shift/types"; + +export type ShiftBridge = BridgeApi; + +export function createBridge(): ShiftBridge { + return new Bridge() as ShiftBridge; +} diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json new file mode 100644 index 00000000..98bcaccb --- /dev/null +++ b/packages/bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/types/docs/DOCS.md b/packages/types/docs/DOCS.md index 49ac395c..80b29491 100644 --- a/packages/types/docs/DOCS.md +++ b/packages/types/docs/DOCS.md @@ -1,147 +1,103 @@ # @shift/types -Shared TypeScript types for the Shift font editor -- provides the canonical type definitions consumed by all TypeScript packages and the renderer. +Shared DTO and primitive TypeScript types for Shift. This package owns branded IDs, math primitives, and bridge DTOs generated from `shift-bridge`. ## Architecture Invariants -- **Architecture Invariant: CRITICAL:** Files in `src/generated/` are auto-generated by `ts-rs` from Rust structs in `shift-core` and `shift-ir`. Never edit them manually -- they are overwritten on regeneration. The Rust structs are the source of truth. -- **Architecture Invariant: CRITICAL:** After regeneration, `scripts/patch-generated-types.ts` must be run to inject branded ID imports (`PointId`, `ContourId`, `AnchorId`) into generated files. `ts-rs` does not support `#[ts(type = "...")]` with external imports, so the patch script adds the `import type { ... } from "../ids"` lines. Skipping this step produces generated files that reference branded types without importing them -- TypeScript will error. -- **Architecture Invariant:** Domain types in `domain.ts` are deeply `Readonly` wrappers over the generated snapshot types. Field names and shapes are identical to the generated types; only mutability is removed. All renderer and tool code must use the domain types (`Point`, `Contour`, `Glyph`, etc.), never the raw generated snapshot types. -- **Architecture Invariant:** `PointId`, `ContourId`, and `AnchorId` are branded string types. TypeScript never generates IDs -- they always originate from Rust. Use `asPointId()` / `asContourId()` / `asAnchorId()` to cast raw strings from Rust into branded types. -- **Architecture Invariant:** This package ships raw `.ts` source (no build step). `package.json` points `main` and `types` directly at `src/index.ts`. Consumers import via workspace resolution. +- **Architecture Invariant: CRITICAL:** `src/bridge/generated.ts` is generated from `crates/shift-bridge/index.d.ts` by `scripts/generate-bridge-types.mjs`. Never edit it manually. +- **Architecture Invariant: CRITICAL:** `@shift/types` is the canonical TypeScript DTO facade for the native bridge. It strips `Napi*` prefixes and exports type-only DTOs. +- **Architecture Invariant:** Editor/domain snapshot types do not live here. +- **Architecture Invariant:** `PointId`, `ContourId`, and `AnchorId` are branded string types. TypeScript never generates IDs -- they originate from Rust. Use `asPointId()` / `asContourId()` / `asAnchorId()` to cast raw strings into branded types. +- **Architecture Invariant:** This package ships raw `.ts` source. `package.json` points `main` and `types` directly at `src/index.ts`. ## Codemap ``` packages/types/src/ - index.ts -- public barrel; re-exports everything consumers need - math.ts -- Point2D, Rect2D, TransformMatrix (hand-authored) - ids.ts -- branded PointId/ContourId/AnchorId + cast helpers (hand-authored) - font.ts -- re-exports generated types + domain types together - domain.ts -- deeply Readonly wrappers over generated snapshot types - generated/ - index.ts -- barrel for all generated files - PointSnapshot.ts -- from shift-core snapshot.rs - ContourSnapshot.ts -- from shift-core snapshot.rs - GlyphSnapshot.ts -- from shift-core snapshot.rs - AnchorSnapshot.ts -- from shift-core snapshot.rs - CommandResult.ts -- from shift-core snapshot.rs - PointType.ts -- "onCurve" | "offCurve", from shift-core snapshot.rs - RenderPointSnapshot.ts -- from shift-core snapshot.rs - RenderContourSnapshot.ts -- from shift-core snapshot.rs - FontMetrics.ts -- from shift-ir metrics.rs - FontMetadata.ts -- from shift-ir font.rs - DecomposedTransform.ts -- from shift-ir component.rs - RuleId.ts -- smoothness constraint rule identifiers - MatchedRule.ts -- matched smoothness rule with affected points + index.ts -- root barrel: IDs, math, bridge DTOs + math.ts -- Point2D, Rect2D, TransformMatrix + ids.ts -- branded IDs + cast helpers + bridge/ + index.ts -- stable bridge DTO barrel + generated.ts -- generated from shift-bridge/index.d.ts ``` -## Key Types +## Bridge DTOs -### Generated (from Rust, mutable) +Import from `@shift/types`. -- **`PointSnapshot`** -- `{ id: PointId, x, y, pointType: PointType, smooth }`. A single on-curve or off-curve point. -- **`ContourSnapshot`** -- `{ id: ContourId, points: PointSnapshot[], closed }`. An open or closed path. -- **`GlyphSnapshot`** -- full glyph state: unicode, name, xAdvance, contours, anchors, compositeContours, activeContourId. -- **`AnchorSnapshot`** -- `{ id: AnchorId, name, x, y }`. A named glyph attachment point. -- **`CommandResult`** -- response from Rust commands: success/error, optional snapshot, affectedPointIds, undo/redo state. -- **`PointType`** -- `"onCurve" | "offCurve"`. -- **`RenderPointSnapshot`** / **`RenderContourSnapshot`** -- ID-less versions of point/contour for flattened composite rendering. -- **`FontMetrics`** -- per-font metrics: unitsPerEm, ascender, descender, capHeight, xHeight, etc. -- **`FontMetadata`** -- font name table fields: familyName, styleName, designer, license, etc. -- **`DecomposedTransform`** -- decomposed affine: translate, rotate, scale, skew, with transform center. -- **`RuleId`** / **`MatchedRule`** -- smoothness constraint rule identifiers and matched rule data. +- `BridgeApi` -- type-only native bridge API surface. +- `FontMetadata` / `FontMetrics` -- font-level DTOs returned by `Bridge`. +- `GlyphRecord` -- committed glyph list record: name, unicodes, component base names. +- `GlyphStructure` -- stable glyph structure: contours, anchors, components. +- `GlyphStructureChange` -- structural edit result: new structure, `Float64Array` values, changed IDs. +- `GlyphValueChange` -- hot-path value edit result: `Float64Array` values, changed IDs. +- `PointType` -- bridge point type union. -### Domain (hand-authored, deeply readonly) +## Generation -- **`Point`** -- `Readonly`. Use everywhere in renderer/tool code. -- **`Contour`** -- `Readonly` with `readonly points: readonly Point[]`. -- **`Glyph`** -- `Readonly` with readonly contour, anchor, and compositeContour arrays. -- **`Anchor`** -- `Readonly`. -- **`RenderPoint`** / **`RenderContour`** -- readonly versions of render snapshots. -- **`CompositeComponent`** / **`CompositeGlyph`** -- component breakdown of composite/reference glyphs. +`shift-bridge` owns the low-level NAPI declaration file at `crates/shift-bridge/index.d.ts`. `scripts/generate-bridge-types.mjs` reads that declaration file and emits `src/bridge/generated.ts`. -### Math (hand-authored) +The generator: -- **`Point2D`** -- `{ x: number, y: number }`. -- **`Rect2D`** -- `{ x, y, width, height, left, top, right, bottom }`. -- **`TransformMatrix`** -- `[A, B, C, D, E, F]` tuple, standard 2D affine matrix. +- removes the runtime `Bridge` class and emits a type-only `BridgeApi`; +- strips `Napi` prefixes from exported DTO names; +- preserves branded ID imports from `../ids`; +- preserves typed arrays such as `Float64Array` and `BigUint64Array`. -### IDs (hand-authored, branded) +Turbo owns the cache key: -- **`PointId`** / **`ContourId`** / **`AnchorId`** -- branded `string & { readonly [Brand]: ... }` types. Prevents mixing ID types at compile time. -- **`asPointId`** / **`asContourId`** / **`asAnchorId`** -- cast raw strings from Rust into branded types. -- **`isValidPointId`** / **`isValidContourId`** / **`isValidAnchorId`** -- runtime type guards (non-empty string check). +```text +task: + generate:bridge-types -## How it works +inputs: + crates/shift-bridge/index.d.ts + scripts/generate-bridge-types.mjs -### Generated vs. domain type relationship - -Rust structs in `shift-core` (snapshot.rs) and `shift-ir` (metrics.rs, font.rs, component.rs) are annotated with `#[derive(TS)]` and `#[ts(export, export_to = "...")]`. Running `cargo test` on these crates invokes `ts-rs`, which writes `.ts` files directly into `packages/types/src/generated/`. - -The generated types use mutable `Array` fields (matching Rust `Vec`). The domain layer (`domain.ts`) wraps each generated type with `Readonly<>` and converts `Array` to `readonly T[]`, producing deeply frozen views. This prevents accidental mutation of font engine state in the renderer. - -The two-layer design: - -1. **Generated types** (`*Snapshot`) -- used only inside `domain.ts` and in the NAPI bridge layer. -2. **Domain types** (`Point`, `Contour`, `Glyph`, etc.) -- used everywhere else. Same shape, just immutable. - -### Branded IDs - -`ts-rs` emits ID fields as `string` in the generated files. The `#[ts(type = "PointId")]` annotation tells `ts-rs` to use the branded type name, but `ts-rs` does not generate import statements. The `patch-generated-types.ts` script adds the missing `import type { PointId } from "../ids"` lines post-generation. - -### Package exports - -Two export paths exist: +output: + packages/types/src/bridge/generated.ts +``` -- `@shift/types` -- the main barrel: domain types, generated types, math types, and IDs. -- `@shift/types/generated` -- direct access to raw generated types (used by the NAPI bridge). +Run: -## Workflow recipes +```bash +pnpm generate:bridge-types +``` -### Regenerate types after Rust changes +`turbo run typecheck` depends on `generate:bridge-types`, so stale bridge declarations are refreshed before typecheck when inputs change. -1. Modify the Rust struct (e.g., add a field to `PointSnapshot` in `shift-core/src/snapshot.rs`). -2. Run `cargo test --package shift-core --package shift-ir` -- `ts-rs` writes updated `.ts` files. -3. Run `npx tsx scripts/patch-generated-types.ts` -- injects branded ID imports. -4. If you added a new generated file, add a `export type { ... }` line to `src/generated/index.ts` and re-export through `src/font.ts` and `src/index.ts`. -5. If the new type needs an immutable domain wrapper, add it to `domain.ts`. -6. Run `pnpm typecheck` from the package root to verify. +## Workflow Recipes -### Add a new hand-authored type +### Regenerate bridge types after Rust bridge changes -1. Add the type to the appropriate file (`math.ts`, `ids.ts`, or `domain.ts`). -2. Re-export from `font.ts` (if font-related) or directly from `index.ts`. -3. Run `pnpm typecheck`. +1. Update `shift-wire` and/or `shift-wire::bridges::napi`. +2. Rebuild `shift-bridge` declarations. +3. Run `pnpm generate:bridge-types`. +4. Import the result from `@shift/types`. +5. Run `pnpm typecheck`. ### Add a new branded ID type 1. Add the brand symbol, branded type, cast function, and type guard to `ids.ts`. 2. Export from `index.ts`. -3. If generated files reference the new ID, add a patch entry to `FILE_IMPORTS` in `scripts/patch-generated-types.ts`. +3. If bridge declarations reference the new ID, make sure `crates/shift-bridge/dts-header.d.ts` imports it from `@shift/types`. ## Gotchas -- **Stale generated types**: If you modify Rust structs but forget to run `cargo test`, the generated `.ts` files will be out of sync with the Rust source. `pnpm typecheck` will pass (it only checks TS consistency), but runtime data will not match the types. -- **Missing patch step**: Running `cargo test` without the patch script leaves generated files that reference `PointId`/`ContourId`/`AnchorId` without importing them. TypeScript will report "cannot find name" errors. -- **`MatchedRule.pointId` is `string`, not `PointId`**: The generated `MatchedRule` type uses plain `string` for `pointId` and `affectedPointIds`, not branded types. This is a known inconsistency in the Rust source. -- **No build step**: This package has no compilation. If you add non-TS files or try to emit `.d.ts`, the current setup won't support it. -- **`generated/index.ts` is hand-maintained**: Despite the files it re-exports being auto-generated, the barrel file itself is not. Adding a new generated type requires manually adding the re-export. +- **Stale bridge DTOs:** Run `pnpm generate:bridge-types` after rebuilding `shift-bridge/index.d.ts`. Turbo caches this based on the declaration input and generator script. +- **No editor/domain types:** If renderer code needs editor-owned shapes, define them in the app or the relevant editor package. Do not add snapshot-era types back here. +- **No build step:** This package has no compilation. If you add non-TS files or try to emit `.d.ts`, the current setup will not support it. ## Verification -- `pnpm typecheck` -- runs `tsc --noEmit` to verify internal type consistency. -- `cargo test --package shift-core --package shift-ir` -- regenerates all `ts-rs` output files. -- `npx tsx scripts/patch-generated-types.ts` -- verifies/applies branded ID import patches. +- `pnpm generate:bridge-types` -- regenerates the bridge DTO facade. +- `pnpm --filter @shift/types typecheck` -- verifies package types. +- `pnpm typecheck` -- verifies the workspace after generation. ## Related -- **`FontEngine`** (shift-node) -- the NAPI bridge class that returns `CommandResult` and `GlyphSnapshot` as JSON. -- **`NativeBridge`** (renderer bridge) -- parses JSON responses from `FontEngine` into these types. -- **`snapshot.rs`** (shift-core) -- Rust source of truth for `PointSnapshot`, `ContourSnapshot`, `GlyphSnapshot`, `CommandResult`, and render snapshot types. -- **`metrics.rs`** (shift-ir) -- Rust source for `FontMetrics`. -- **`font.rs`** (shift-ir) -- Rust source for `FontMetadata`. -- **`component.rs`** (shift-ir) -- Rust source for `DecomposedTransform`. -- **`patch-generated-types.ts`** (scripts) -- post-generation patch for branded ID imports. -- **`@shift/rules`** -- consumes `MatchedRule`, `RuleId`, and snapshot types. -- **`@shift/geo`** -- consumes `Point2D`, `TransformMatrix` from math types. +- `shift-wire` -- Rust source of truth for bridge DTO semantics. +- `shift-bridge` -- NAPI bridge class and generated declaration source. +- `scripts/generate-bridge-types.mjs` -- declaration transformer for the generated bridge DTO internals. +- `@shift/bridge` -- runtime package that creates the native bridge instance. diff --git a/packages/types/package.json b/packages/types/package.json index 70cb3418..e3425a3c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -10,13 +10,10 @@ ".": { "types": "./src/index.ts", "import": "./src/index.ts" - }, - "./generated": { - "types": "./src/generated/index.ts", - "import": "./src/generated/index.ts" } }, "scripts": { + "generate:bridge-types": "node ../../scripts/generate-bridge-types.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts new file mode 100644 index 00000000..362aad8c --- /dev/null +++ b/packages/types/src/bridge/generated.ts @@ -0,0 +1,214 @@ +import type { + ContourId, + PointId, + AnchorId, + ComponentId, + GuidelineId, + LayerId, + SourceId, +} from "../ids"; + +// This file is generated from crates/shift-bridge/index.d.ts. +// Run `pnpm generate:bridge-types` after rebuilding shift-bridge declarations. +// Do not edit this file manually. + +export type GlyphName = string; +export type Unicode = number; + +export interface BridgeApi { + loadFont(path: string): void; + saveFont(path: string): Promise; + getMetadata(): FontMetadata; + getMetrics(): FontMetrics; + getGlyphCount(): number; + getGlyphs(): Array; + getGlyphState(glyphRef: GlyphHandle): GlyphState | null; + isVariable(): boolean; + getAxes(): Array; + getSources(): Array; + startEditSession(glyphRef: GlyphHandle): void; + getLiveVersion(): number; + getPersistedVersion(): number; + isDirty(): boolean; + endEditSession(): void; + hasEditSession(): boolean; + getEditingUnicode(): Unicode | null; + getEditingGlyphName(): GlyphName | null; + setXAdvance(width: number): GlyphValueChange; + translateLayer(dx: number, dy: number): GlyphValueChange; + addPoint( + contourId: ContourId, + x: number, + y: number, + pointType: PointType, + smooth: boolean, + ): GlyphStructureChange; + insertPointBefore( + beforePointId: PointId, + x: number, + y: number, + pointType: PointType, + smooth: boolean, + ): GlyphStructureChange; + addContour(): GlyphStructureChange; + openContour(contourId: ContourId): GlyphStructureChange; + closeContour(contourId: ContourId): GlyphStructureChange; + reverseContour(contourId: ContourId): GlyphStructureChange; + applyBooleanOp( + contourIdA: ContourId, + contourIdB: ContourId, + operation: string, + ): GlyphStructureChange; + removePoints(pointIds: Array): GlyphStructureChange; + toggleSmooth(pointId: PointId): GlyphStructureChange; + /** + * Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. + * Coords are interleaved [x0, y0, x1, y1, ...]. + */ + setPositions( + pointIds?: BigUint64Array | undefined | null, + pointCoords?: Float64Array | undefined | null, + anchorIds?: BigUint64Array | undefined | null, + anchorCoords?: Float64Array | undefined | null, + ): GlyphValueChange; + restoreState(structure: GlyphStructure, values: Float64Array): GlyphStructureChange; +} + +export interface GlyphHandle { + name: GlyphName; + unicode?: Unicode; +} +export interface AnchorData { + id: AnchorId; + name?: string; +} + +export interface Axis { + tag: string; + name: string; + minimum: number; + default: number; + maximum: number; + hidden: boolean; +} + +export interface AxisTent { + axisTag: string; + lower: number; + peak: number; + upper: number; +} + +export interface ComponentData { + id: ComponentId; + baseGlyphName: GlyphName; +} + +export interface ContourData { + id: ContourId; + points: Array; + closed: boolean; +} + +export interface FontMetadata { + familyName?: string; + styleName?: string; + versionMajor?: number; + versionMinor?: number; + copyright?: string; + trademark?: string; + designer?: string; + designerUrl?: string; + manufacturer?: string; + manufacturerUrl?: string; + license?: string; + licenseUrl?: string; + description?: string; + note?: string; +} + +export interface FontMetrics { + unitsPerEm: number; + ascender: number; + descender: number; + capHeight?: number; + xHeight?: number; + lineGap?: number; + italicAngle?: number; + underlinePosition?: number; + underlineThickness?: number; +} + +export interface GlyphChangedEntities { + pointIds: Array; + contourIds: Array; + anchorIds: Array; + guidelineIds: Array; + componentIds: Array; +} + +export interface GlyphMaster { + sourceId: SourceId; + sourceName: string; + isDefaultSource: boolean; + location: Location; + structure: GlyphStructure; + values: Float64Array; +} + +export interface GlyphRecord { + name: GlyphName; + unicodes: Array; + componentBaseGlyphNames: Array; +} + +export interface GlyphState { + structure: GlyphStructure; + /** Numeric glyph state ordered to match `GlyphStructure`. */ + values: Float64Array; + variationData?: GlyphVariationData; +} + +export interface GlyphStructure { + contours: Array; + anchors: Array; + components: Array; +} + +export interface GlyphStructureChange { + structure: GlyphStructure; + values: Float64Array; + changed: GlyphChangedEntities; +} + +export interface GlyphValueChange { + values: Float64Array; + changed: GlyphChangedEntities; +} + +export interface GlyphVariationData { + /** One entry per region. Inner = tents on the axes the region depends on. */ + regions: Array>; + /** Deltas are flattened in `GlyphState::values` order. */ + deltas: Array; +} + +export interface Location { + values: Record; +} + +export interface PointData { + id: PointId; + pointType: PointType; + smooth: boolean; +} + +export type PointType = "onCurve" | "offCurve"; + +export interface Source { + id: SourceId; + name: string; + location: Location; + layerId: LayerId; + filename?: string; +} diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts new file mode 100644 index 00000000..355d4e5e --- /dev/null +++ b/packages/types/src/bridge/index.ts @@ -0,0 +1,25 @@ +export type { + AnchorData, + Axis, + AxisTent, + BridgeApi, + ComponentData, + ContourData, + FontMetadata, + FontMetrics, + GlyphChangedEntities, + GlyphHandle, + GlyphMaster, + GlyphName, + GlyphRecord, + GlyphState, + GlyphStructure, + GlyphStructureChange, + GlyphValueChange, + GlyphVariationData, + Location, + PointData, + PointType, + Source, + Unicode, +} from "./generated"; diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts deleted file mode 100644 index d3cd270a..00000000 --- a/packages/types/src/domain.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Domain types — deeply readonly wrappers over generated Rust snapshot types. - * - * The generated types in `./generated/` (produced by `ts-rs` from Rust structs) - * are mutable and use mutable arrays. The domain layer re-exports them as - * deeply frozen views so that the app never accidentally mutates font engine - * state. Field names and shapes are identical to the generated types; only - * mutability is removed. - * - * Use these types everywhere in the renderer and tool layers. The generated - * types should only be referenced inside this file and in the NAPI bridge. - * - * @module - */ -import type { - GlyphSnapshot, - ContourSnapshot, - PointSnapshot, - AnchorSnapshot, - RenderPointSnapshot, - RenderContourSnapshot, - DecomposedTransform as DecomposedTransformGenerated, - Source as SourceGenerated, -} from "./generated"; - -/** - * An anchor or control point. Immutable view of {@link PointSnapshot}. - * The `isSmooth` flag indicates G2 (tangent-continuous) Bezier continuity: - * when true, the two control handles are constrained to be collinear through - * the anchor. - */ -export type Point = Readonly; - -/** A named glyph attachment anchor. Immutable view of {@link AnchorSnapshot}. */ -export type Anchor = Readonly; - -/** Decomposed affine transform (translate, rotate, scale, skew). Immutable view. */ -export type DecomposedTransform = Readonly; - -/** - * A single contour (open or closed path) with its points frozen. - * Open contours have distinct start/end anchors (used by the Pen tool to - * extend or close the path). Closed contours loop back to the first point. - * Wraps {@link ContourSnapshot} with a `readonly` point array of {@link Point}. - */ -export type Contour = Readonly> & { - readonly points: readonly Point[]; -}; - -/** - * A full glyph with all contours and their points frozen. - * Contours are ordered by creation time (newest last). The order is - * significant for rendering (later contours draw on top) and for - * hit-testing priority. - * Wraps {@link GlyphSnapshot} with a `readonly` contour array of {@link Contour}. - */ -export type Glyph = Readonly> & { - readonly contours: readonly Contour[]; - readonly anchors: readonly Anchor[]; -}; - -/** A render-only point used in flattened composite contours. */ -export type RenderPoint = Readonly; - -/** A render-only contour produced by flattening component references. */ -export type RenderContour = Readonly> & { - readonly points: readonly RenderPoint[]; -}; - -/** A single component of a composite glyph. */ -export type CompositeComponent = { - readonly componentGlyphName: string; - readonly sourceUnicodes: readonly number[]; - readonly contours: readonly RenderContour[]; -}; - -/** Composite glyph data — the component breakdown of a composite/reference glyph. */ -export type CompositeGlyph = { - readonly glyphName: string; - readonly components: readonly CompositeComponent[]; -}; - -/** - * A designspace location keyed by axis tag. - * - * The generated `Location` type marshals from Rust as `HashMap`, - * which gives optional values per key. In the renderer we always populate - * every active axis, so the domain shape is a non-optional record. - * Conversion happens once, inside `NativeBridge`; everything downstream uses - * `AxisLocation`. - */ -export type AxisLocation = Record; - -/** - * A source/master with its location resolved against the font's axes. - * Wraps the generated `Source` but replaces its `Location` field with - * {@link AxisLocation}. - */ -export type Source = Readonly> & { - readonly location: AxisLocation; -}; diff --git a/packages/types/src/font.ts b/packages/types/src/font.ts deleted file mode 100644 index 09ac3540..00000000 --- a/packages/types/src/font.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Font-related types - */ - -// Re-export auto-generated types from Rust -export type { - PointType, - PointSnapshot, - ContourSnapshot, - AnchorSnapshot, - RenderPointSnapshot, - RenderContourSnapshot, - GlyphSnapshot, - GlyphGeometry, - MasterSnapshot, - InterpolationResult, - SourceError, - AxisTent, - GlyphVariationData, - CommandResult, - RuleId, - MatchedRule, - FontMetrics, - FontMetadata, - Axis, - Component, - GlyphData, -} from "./generated"; - -// Domain types (for Editor API) -export type { - Point, - Anchor, - RenderPoint, - Contour, - RenderContour, - Glyph, - DecomposedTransform, - CompositeComponent, - CompositeGlyph, - AxisLocation, - Source, -} from "./domain"; diff --git a/packages/types/src/generated/AnchorSnapshot.ts b/packages/types/src/generated/AnchorSnapshot.ts deleted file mode 100644 index 91d3b857..00000000 --- a/packages/types/src/generated/AnchorSnapshot.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AnchorId } from "../ids"; - - -export type AnchorSnapshot = { id: AnchorId, name: string | null, x: number, y: number, }; diff --git a/packages/types/src/generated/Axis.ts b/packages/types/src/generated/Axis.ts deleted file mode 100644 index 185f21dc..00000000 --- a/packages/types/src/generated/Axis.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type Axis = { tag: string, name: string, minimum: number, default: number, maximum: number, hidden: boolean, }; diff --git a/packages/types/src/generated/AxisTent.ts b/packages/types/src/generated/AxisTent.ts deleted file mode 100644 index ca476146..00000000 --- a/packages/types/src/generated/AxisTent.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AxisTent = { axisTag: string, lower: number, peak: number, upper: number, }; diff --git a/packages/types/src/generated/CommandResult.ts b/packages/types/src/generated/CommandResult.ts deleted file mode 100644 index f14b7883..00000000 --- a/packages/types/src/generated/CommandResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PointId } from "../ids"; - -import type { GlyphSnapshot } from "./GlyphSnapshot"; - -export type CommandResult = { success: boolean, snapshot: GlyphSnapshot | null, error: string | null, affectedPointIds: Array | null, canUndo: boolean, canRedo: boolean, }; diff --git a/packages/types/src/generated/Component.ts b/packages/types/src/generated/Component.ts deleted file mode 100644 index 4b553312..00000000 --- a/packages/types/src/generated/Component.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DecomposedTransform } from "./DecomposedTransform"; - -export type Component = { baseGlyphName: string, transform: DecomposedTransform, }; diff --git a/packages/types/src/generated/ContourSnapshot.ts b/packages/types/src/generated/ContourSnapshot.ts deleted file mode 100644 index 4a53eda4..00000000 --- a/packages/types/src/generated/ContourSnapshot.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContourId } from "../ids"; - -import type { PointSnapshot } from "./PointSnapshot"; - -export type ContourSnapshot = { id: ContourId, points: Array, closed: boolean, }; diff --git a/packages/types/src/generated/DecomposedTransform.ts b/packages/types/src/generated/DecomposedTransform.ts deleted file mode 100644 index ae8d340f..00000000 --- a/packages/types/src/generated/DecomposedTransform.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Decomposed 2D transformation with explicit scale, rotation, skew, and translation. - * Composition order: translate to center → rotate → scale → skew → translate back - */ -export type DecomposedTransform = { translateX: number, translateY: number, rotation: number, scaleX: number, scaleY: number, skewX: number, skewY: number, tCenterX: number, tCenterY: number, }; diff --git a/packages/types/src/generated/FontMetadata.ts b/packages/types/src/generated/FontMetadata.ts deleted file mode 100644 index f466a316..00000000 --- a/packages/types/src/generated/FontMetadata.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type FontMetadata = { familyName: string | null, styleName: string | null, versionMajor: number | null, versionMinor: number | null, copyright: string | null, trademark: string | null, designer: string | null, designerUrl: string | null, manufacturer: string | null, manufacturerUrl: string | null, license: string | null, licenseUrl: string | null, description: string | null, note: string | null, }; diff --git a/packages/types/src/generated/FontMetrics.ts b/packages/types/src/generated/FontMetrics.ts deleted file mode 100644 index baa6b6be..00000000 --- a/packages/types/src/generated/FontMetrics.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type FontMetrics = { unitsPerEm: number, ascender: number, descender: number, capHeight: number | null, xHeight: number | null, lineGap: number | null, italicAngle: number | null, underlinePosition: number | null, underlineThickness: number | null, }; diff --git a/packages/types/src/generated/GlyphData.ts b/packages/types/src/generated/GlyphData.ts deleted file mode 100644 index 56d28075..00000000 --- a/packages/types/src/generated/GlyphData.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Component } from "./Component"; -import type { GlyphGeometry } from "./GlyphGeometry"; -import type { GlyphVariationData } from "./GlyphVariationData"; - -export type GlyphData = { geometry: GlyphGeometry, variationData: GlyphVariationData | null, components: Array, }; diff --git a/packages/types/src/generated/GlyphGeometry.ts b/packages/types/src/generated/GlyphGeometry.ts deleted file mode 100644 index 2a3f13dd..00000000 --- a/packages/types/src/generated/GlyphGeometry.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AnchorSnapshot } from "./AnchorSnapshot"; -import type { ContourSnapshot } from "./ContourSnapshot"; - -export type GlyphGeometry = { xAdvance: number, contours: Array, anchors: Array, }; diff --git a/packages/types/src/generated/GlyphSnapshot.ts b/packages/types/src/generated/GlyphSnapshot.ts deleted file mode 100644 index 7a61c4ec..00000000 --- a/packages/types/src/generated/GlyphSnapshot.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContourId } from "../ids"; - -import type { AnchorSnapshot } from "./AnchorSnapshot"; -import type { ContourSnapshot } from "./ContourSnapshot"; -import type { RenderContourSnapshot } from "./RenderContourSnapshot"; - -export type GlyphSnapshot = { unicode: number, name: string, xAdvance: number, contours: Array, anchors: Array, compositeContours: Array, activeContourId: ContourId | null, }; diff --git a/packages/types/src/generated/GlyphVariationData.ts b/packages/types/src/generated/GlyphVariationData.ts deleted file mode 100644 index da446126..00000000 --- a/packages/types/src/generated/GlyphVariationData.ts +++ /dev/null @@ -1,13 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AxisTent } from "./AxisTent"; - -export type GlyphVariationData = { -/** - * One entry per region. Inner = tents on the axes the region depends on. - */ -regions: Array>, -/** - * Same length as `regions`. Each entry = flat values matching `flatten()` order: - * [xAdvance, p0.x, p0.y, ..., a0.x, a0.y, ...]. - */ -deltas: Array>, }; diff --git a/packages/types/src/generated/InterpolationResult.ts b/packages/types/src/generated/InterpolationResult.ts deleted file mode 100644 index 1e3e8edc..00000000 --- a/packages/types/src/generated/InterpolationResult.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { GlyphGeometry } from "./GlyphGeometry"; -import type { SourceError } from "./SourceError"; - -export type InterpolationResult = { geometry: GlyphGeometry, errors: Array, }; diff --git a/packages/types/src/generated/Location.ts b/packages/types/src/generated/Location.ts deleted file mode 100644 index dbbf8be7..00000000 --- a/packages/types/src/generated/Location.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type Location = { values: { [key in string]?: number }, }; diff --git a/packages/types/src/generated/MasterSnapshot.ts b/packages/types/src/generated/MasterSnapshot.ts deleted file mode 100644 index c80e0bf8..00000000 --- a/packages/types/src/generated/MasterSnapshot.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { GlyphGeometry } from "./GlyphGeometry"; -import type { Location } from "./Location"; - -export type MasterSnapshot = { sourceId: string, sourceName: string, isDefaultSource: boolean, location: Location, geometry: GlyphGeometry, }; diff --git a/packages/types/src/generated/MatchedRule.ts b/packages/types/src/generated/MatchedRule.ts deleted file mode 100644 index 025c409a..00000000 --- a/packages/types/src/generated/MatchedRule.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RuleId } from "./RuleId"; - -export type MatchedRule = { - pointId: string; - ruleId: RuleId; - description: string; - pattern: string; - affectedPointIds: Array; -}; diff --git a/packages/types/src/generated/PointSnapshot.ts b/packages/types/src/generated/PointSnapshot.ts deleted file mode 100644 index 2cc0efd5..00000000 --- a/packages/types/src/generated/PointSnapshot.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PointId } from "../ids"; - -import type { PointType } from "./PointType"; - -export type PointSnapshot = { id: PointId, x: number, y: number, pointType: PointType, smooth: boolean, }; diff --git a/packages/types/src/generated/PointType.ts b/packages/types/src/generated/PointType.ts deleted file mode 100644 index 482e7b4c..00000000 --- a/packages/types/src/generated/PointType.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type PointType = "onCurve" | "offCurve"; diff --git a/packages/types/src/generated/RenderContourSnapshot.ts b/packages/types/src/generated/RenderContourSnapshot.ts deleted file mode 100644 index 0582cd9c..00000000 --- a/packages/types/src/generated/RenderContourSnapshot.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RenderPointSnapshot } from "./RenderPointSnapshot"; - -export type RenderContourSnapshot = { points: Array, closed: boolean, }; diff --git a/packages/types/src/generated/RenderPointSnapshot.ts b/packages/types/src/generated/RenderPointSnapshot.ts deleted file mode 100644 index 3394140b..00000000 --- a/packages/types/src/generated/RenderPointSnapshot.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PointType } from "./PointType"; - -export type RenderPointSnapshot = { x: number, y: number, pointType: PointType, smooth: boolean, }; diff --git a/packages/types/src/generated/RuleId.ts b/packages/types/src/generated/RuleId.ts deleted file mode 100644 index 724b72ee..00000000 --- a/packages/types/src/generated/RuleId.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RuleId = - | "moveRightHandle" - | "moveLeftHandle" - | "moveBothHandles" - | "maintainTangencyRight" - | "maintainTangencyLeft"; diff --git a/packages/types/src/generated/Source.ts b/packages/types/src/generated/Source.ts deleted file mode 100644 index 33e14357..00000000 --- a/packages/types/src/generated/Source.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Location } from "./Location"; - -export type Source = { id: string, name: string, location: Location, layerId: string, filename: string | null, }; diff --git a/packages/types/src/generated/SourceError.ts b/packages/types/src/generated/SourceError.ts deleted file mode 100644 index 0fa35723..00000000 --- a/packages/types/src/generated/SourceError.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SourceError = { sourceIndex: number, sourceName: string, message: string, }; diff --git a/packages/types/src/generated/index.ts b/packages/types/src/generated/index.ts deleted file mode 100644 index 8ea1b641..00000000 --- a/packages/types/src/generated/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Re-export all generated types from Rust -// These files are auto-generated by ts-rs - do not edit manually -// Run `cargo test --package shift-core --package shift-ir` to regenerate - -export type { PointType } from "./PointType"; -export type { PointSnapshot } from "./PointSnapshot"; -export type { ContourSnapshot } from "./ContourSnapshot"; -export type { AnchorSnapshot } from "./AnchorSnapshot"; -export type { RenderPointSnapshot } from "./RenderPointSnapshot"; -export type { RenderContourSnapshot } from "./RenderContourSnapshot"; -export type { GlyphSnapshot } from "./GlyphSnapshot"; -export type { GlyphGeometry } from "./GlyphGeometry"; -export type { MasterSnapshot } from "./MasterSnapshot"; -export type { InterpolationResult } from "./InterpolationResult"; -export type { SourceError } from "./SourceError"; -export type { AxisTent } from "./AxisTent"; -export type { GlyphVariationData } from "./GlyphVariationData"; -export type { CommandResult } from "./CommandResult"; -export type { RuleId } from "./RuleId"; -export type { MatchedRule } from "./MatchedRule"; -export type { FontMetrics } from "./FontMetrics"; -export type { FontMetadata } from "./FontMetadata"; -export type { DecomposedTransform } from "./DecomposedTransform"; -export type { Component } from "./Component"; -export type { GlyphData } from "./GlyphData"; -export type { Axis } from "./Axis"; -export type { Location } from "./Location"; -export type { Source } from "./Source"; diff --git a/packages/types/src/ids.ts b/packages/types/src/ids.ts index aea06fc5..f3e15403 100644 --- a/packages/types/src/ids.ts +++ b/packages/types/src/ids.ts @@ -9,6 +9,10 @@ declare const PointIdBrand: unique symbol; declare const ContourIdBrand: unique symbol; declare const AnchorIdBrand: unique symbol; +declare const ComponentIdBrand: unique symbol; +declare const GuidelineIdBrand: unique symbol; +declare const LayerIdBrand: unique symbol; +declare const SourceIdBrand: unique symbol; /** * A point identifier from Rust. @@ -30,6 +34,30 @@ export type ContourId = string & { */ export type AnchorId = string & { readonly [AnchorIdBrand]: typeof AnchorIdBrand }; +/** + * A component identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +export type ComponentId = string & { readonly [ComponentIdBrand]: typeof ComponentIdBrand }; + +/** + * A guideline identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +export type GuidelineId = string & { readonly [GuidelineIdBrand]: typeof GuidelineIdBrand }; + +/** + * A layer identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +export type LayerId = string & { readonly [LayerIdBrand]: typeof LayerIdBrand }; + +/** + * A source identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +export type SourceId = string & { readonly [SourceIdBrand]: typeof SourceIdBrand }; + /** * Convert a string ID from Rust to a typed PointId. * Use this when receiving IDs from Rust snapshots. @@ -54,6 +82,38 @@ export function asAnchorId(id: string): AnchorId { return id as AnchorId; } +/** + * Convert a string ID from Rust to a typed ComponentId. + * Use this when receiving IDs from Rust snapshots. + */ +export function asComponentId(id: string): ComponentId { + return id as ComponentId; +} + +/** + * Convert a string ID from Rust to a typed GuidelineId. + * Use this when receiving IDs from Rust snapshots. + */ +export function asGuidelineId(id: string): GuidelineId { + return id as GuidelineId; +} + +/** + * Convert a string ID from Rust to a typed LayerId. + * Use this when receiving IDs from Rust snapshots. + */ +export function asLayerId(id: string): LayerId { + return id as LayerId; +} + +/** + * Convert a string ID from Rust to a typed SourceId. + * Use this when receiving IDs from Rust snapshots. + */ +export function asSourceId(id: string): SourceId { + return id as SourceId; +} + /** * Type guard to check if a value is a valid PointId. * Useful for runtime validation in debug builds. @@ -77,3 +137,35 @@ export function isValidContourId(id: unknown): id is ContourId { export function isValidAnchorId(id: unknown): id is AnchorId { return typeof id === "string" && id.length > 0; } + +/** + * Type guard to check if a value is a valid ComponentId. + * Useful for runtime validation in debug builds. + */ +export function isValidComponentId(id: unknown): id is ComponentId { + return typeof id === "string" && id.length > 0; +} + +/** + * Type guard to check if a value is a valid GuidelineId. + * Useful for runtime validation in debug builds. + */ +export function isValidGuidelineId(id: unknown): id is GuidelineId { + return typeof id === "string" && id.length > 0; +} + +/** + * Type guard to check if a value is a valid LayerId. + * Useful for runtime validation in debug builds. + */ +export function isValidLayerId(id: unknown): id is LayerId { + return typeof id === "string" && id.length > 0; +} + +/** + * Type guard to check if a value is a valid SourceId. + * Useful for runtime validation in debug builds. + */ +export function isValidSourceId(id: unknown): id is SourceId { + return typeof id === "string" && id.length > 0; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 98e78f01..76aef6de 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,63 +1,56 @@ /** - * @shift/types - Shared TypeScript types for Shift font editor - * - * This package contains: - * - Auto-generated types from Rust (via ts-rs) - * - Core mathematical types - * - Branded ID types for type safety - * - Font-related types and utilities + * @shift/types - shared DTO and primitive types. */ -// Math types -export type { Point2D, Rect2D, TransformMatrix, A, B, C, D, E, F } from "./math"; - // ID types -export type { PointId, ContourId, AnchorId } from "./ids"; +export type { + PointId, + ContourId, + AnchorId, + ComponentId, + GuidelineId, + LayerId, + SourceId, +} from "./ids"; export { asPointId, asContourId, asAnchorId, + asComponentId, + asGuidelineId, + asLayerId, + asSourceId, isValidPointId, isValidContourId, isValidAnchorId, + isValidComponentId, + isValidGuidelineId, + isValidLayerId, + isValidSourceId, } from "./ids"; -// Font types (includes generated types) export type { - PointType, - PointSnapshot, - ContourSnapshot, - AnchorSnapshot, - RenderPointSnapshot, - RenderContourSnapshot, - GlyphSnapshot, - GlyphGeometry, - MasterSnapshot, - InterpolationResult, - SourceError, + AnchorData, + Axis, AxisTent, - GlyphVariationData, - CommandResult, - RuleId, - MatchedRule, + BridgeApi, + ComponentData, + ContourData, FontMetadata, FontMetrics, - DecomposedTransform, - Axis, - Component, - GlyphData, -} from "./font"; - -// Domain types (for Editor API) -export type { - Point, - Anchor, - RenderPoint, - Contour, - RenderContour, - Glyph, - CompositeComponent, - CompositeGlyph, - AxisLocation, + GlyphChangedEntities, + GlyphHandle, + GlyphMaster, + GlyphName, + GlyphRecord, + GlyphState, + GlyphStructure, + GlyphStructureChange, + GlyphValueChange, + GlyphVariationData, + Location, + PointData, + PointType, Source, -} from "./font"; + Unicode, +} from "./bridge"; diff --git a/packages/types/src/math.ts b/packages/types/src/math.ts deleted file mode 100644 index bbbf719e..00000000 --- a/packages/types/src/math.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Core mathematical types used throughout Shift - */ - -export type Point2D = { x: number; y: number }; - -export type Rect2D = { - x: number; - y: number; - width: number; - height: number; - left: number; - top: number; - right: number; - bottom: number; -}; - -/** X scale factor of the transformation matrix */ -export type A = number; - -/** Y skew factor of the transformation matrix */ -export type B = number; - -/** X skew factor of the transformation matrix */ -export type C = number; - -/** Y scale factor of the transformation matrix */ -export type D = number; - -/** X translation of the transformation matrix */ -export type E = number; - -/** Y translation of the transformation matrix */ -export type F = number; - -/** - * Represents a 2D transformation matrix in the form: - * ``` - * | A C E | - * | B D F | - * | 0 0 1 | - * ``` - * Where: - * - A is x scale factor - * - B is y skew factor - * - C is x skew factor - * - D is y scale factor - * - E is x translation - * - F is y translation - */ -export type TransformMatrix = [A, B, C, D, E, F]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e37fef73..b071d489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@base-ui-components/react': specifier: 1.0.0-rc.0 version: 1.0.0-rc.0(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@shift/bridge': + specifier: workspace:* + version: link:../../packages/bridge '@shift/font': specifier: workspace:* version: link:../../packages/font @@ -99,9 +102,9 @@ importers: regl: specifier: ^2.1.1 version: 2.1.1 - shift-node: + shift-bridge: specifier: workspace:* - version: link:../../crates/shift-node + version: link:../../crates/shift-bridge tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -188,15 +191,35 @@ importers: specifier: ^4.0.17 version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) - crates/shift-node: + crates/shift-bridge: + dependencies: + '@shift/types': + specifier: workspace:^ + version: link:../../packages/types devDependencies: '@napi-rs/cli': - specifier: ^3.6.0 - version: 3.6.0(@emnapi/runtime@1.8.1)(@types/node@25.3.0) + specifier: 3.6.2 + version: 3.6.2(@emnapi/runtime@1.8.1)(@types/node@25.3.0) vitest: specifier: ^4.0.17 version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) + packages/bridge: + dependencies: + '@shift/types': + specifier: workspace:* + version: link:../types + shift-bridge: + specifier: workspace:* + version: link:../../crates/shift-bridge + devDependencies: + '@shift/tsconfig': + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/font: dependencies: '@shift/geo': @@ -982,8 +1005,8 @@ packages: resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} - '@napi-rs/cli@3.6.0': - resolution: {integrity: sha512-aA8m4+9XxnK1+0sr4GplZP0Ze90gkzO8sMKaplOK0zXbLnzsLl6O2BQQt6rTCcTRzIN24wrrByakr/imM+CxhA==} + '@napi-rs/cli@3.6.2': + resolution: {integrity: sha512-jy5rABUh9tbE/vPRzw9kGzGuqZiVslyDQUV8LkvjzqVX/oJMN7g0U1uhtr9L3W1H+iRM/urXHXUf+CE4n8FvLA==} engines: {node: '>= 16'} hasBin: true peerDependencies: @@ -5295,7 +5318,7 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@napi-rs/cli@3.6.0(@emnapi/runtime@1.8.1)(@types/node@25.3.0)': + '@napi-rs/cli@3.6.2(@emnapi/runtime@1.8.1)(@types/node@25.3.0)': dependencies: '@inquirer/prompts': 8.3.2(@types/node@25.3.0) '@napi-rs/cross-toolchain': 1.0.3 @@ -7500,11 +7523,11 @@ snapshots: node-abi@3.75.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 node-api-version@0.2.1: dependencies: - semver: 7.7.2 + semver: 7.7.4 node-fetch@2.7.0(encoding@0.1.13): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68cc83aa..ffc36387 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - 'apps/*' - 'packages/*' - - 'crates/shift-node' + - 'crates/shift-bridge' diff --git a/scripts/generate-bridge-types.mjs b/scripts/generate-bridge-types.mjs new file mode 100644 index 00000000..bae1e280 --- /dev/null +++ b/scripts/generate-bridge-types.mjs @@ -0,0 +1,88 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const inputPath = resolve(repoRoot, "crates/shift-bridge/index.d.ts"); +const outputPath = resolve(repoRoot, "packages/types/src/bridge/generated.ts"); + +const source = readFileSync(inputPath, "utf8"); + +const idImportMatch = source.match(/import type \{\s*([\s\S]*?)\s*\} from "@shift\/types";\n/); +const headerImports = idImportMatch + ? idImportMatch[1] + .split(",") + .map((name) => name.trim()) + .filter(Boolean) + : []; + +const idTypeNames = new Set([ + "PointId", + "ContourId", + "AnchorId", + "ComponentId", + "GuidelineId", + "LayerId", + "SourceId", +]); +const scalarTypeNames = new Set(["GlyphName", "Unicode"]); + +const idImports = headerImports.filter((name) => idTypeNames.has(name)); +const scalarAliases = headerImports + .filter((name) => scalarTypeNames.has(name)) + .map((name) => { + if (name === "GlyphName") { + return "export type GlyphName = string;"; + } + if (name === "Unicode") { + return "export type Unicode = number;"; + } + return ""; + }) + .filter(Boolean); + +function withoutNapiPrefix(text) { + return text.replace(/\bNapi([A-Z]\w*)\b/g, "$1"); +} + +function enumToUnion(name, body) { + const values = [...body.matchAll(/=\s*'([^']+)'/g)].map((match) => `'${match[1]}'`); + return `export type ${withoutNapiPrefix(name)} = ${values.join(" | ")}\n`; +} + +let body = source.replace(idImportMatch?.[0] ?? "", ""); + +const classMatch = body.match(/export declare class Bridge \{([\s\S]*?)\n\}\n\n/); +if (!classMatch) { + throw new Error(`Could not find Bridge class declaration in ${inputPath}`); +} + +const bridgeMethods = classMatch[1] + .split("\n") + .filter((line) => !line.trim().startsWith("constructor(")) + .join("\n") + .replace(/^ /gm, " "); + +const bridgeApi = `export interface BridgeApi {${withoutNapiPrefix(bridgeMethods)}\n}\n\n`; +body = body.replace(classMatch[0], bridgeApi); + +body = body.replace( + /export declare const enum (Napi[A-Za-z0-9_]+) \{([\s\S]*?)\n\}/g, + (_match, name, enumBody) => enumToUnion(name, enumBody), +); +body = withoutNapiPrefix(body); +body = body.replace(/\bexport declare /g, "export "); + +const imports = [ + idImports.length > 0 + ? `import type {\n ${idImports.join(",\n ")},\n} from "../ids";\n` + : "", +] + .filter(Boolean) + .join("") + (idImports.length > 0 ? "\n" : ""); + +const aliases = scalarAliases.length > 0 ? `${scalarAliases.join("\n")}\n\n` : ""; + +const output = `${imports}// This file is generated from crates/shift-bridge/index.d.ts.\n// Run \`pnpm generate:bridge-types\` after rebuilding shift-bridge declarations.\n// Do not edit this file manually.\n\n${aliases}${body}`; + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); diff --git a/scripts/patch-generated-types.ts b/scripts/patch-generated-types.ts deleted file mode 100644 index 2ef4ae00..00000000 --- a/scripts/patch-generated-types.ts +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Post-processes ts-rs generated TypeScript files to add imports for branded ID types. - * - * ts-rs doesn't support importing external types when using #[ts(type = "...")], - * so we patch the generated files to add the necessary imports. - * - * Run this after `cargo test --package shift-core` to ensure types are complete. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const GENERATED_DIR = path.join(__dirname, "../packages/types/src/generated"); - -const FILE_IMPORTS: Record = { - "AnchorSnapshot.ts": ["AnchorId"], - "PointSnapshot.ts": ["PointId"], - "ContourSnapshot.ts": ["ContourId"], - "GlyphSnapshot.ts": ["ContourId"], - "CommandResult.ts": ["PointId"], -}; - -/** Match what pre-commit's trailing-whitespace + end-of-file-fixer do, so the - * hooks don't re-modify these files on every commit attempt. ts-rs emits - * trailing whitespace and no terminating newline. */ -function normalizeWhitespace(content: string): string { - return ( - content - .split("\n") - .map((line) => line.replace(/[ \t]+$/, "")) - .join("\n") - .replace(/\n*$/, "") + "\n" - ); -} - -function patchFile(filename: string, imports: string[]): boolean { - const filePath = path.join(GENERATED_DIR, filename); - - if (!fs.existsSync(filePath)) { - console.warn(`⚠️ File not found: ${filename}`); - return false; - } - - let content = fs.readFileSync(filePath, "utf8"); - - const importStatement = `import type { ${imports.join(", ")} } from "../ids";\n`; - const tsrsComment = - "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually."; - - const alreadyHasImport = content.includes('from "../ids"'); - - if (!alreadyHasImport) { - if (content.startsWith(tsrsComment)) { - content = tsrsComment + "\n" + importStatement + content.slice(tsrsComment.length); - } else { - content = importStatement + content; - } - } - - content = normalizeWhitespace(content); - - // Avoid touching disk if nothing actually changed — keeps git unaware. - const existing = fs.readFileSync(filePath, "utf8"); - if (existing === content) { - console.info(`✓ ${filename} already patched`); - return true; - } - - fs.writeFileSync(filePath, content); - console.info(`✅ Patched ${filename} with imports: ${imports.join(", ")}`); - return true; -} - -function patchNapiDts(): boolean { - const napiDtsPath = path.join(__dirname, "../crates/shift-node/index.d.ts"); - - if (!fs.existsSync(napiDtsPath)) { - console.warn("⚠️ shift-node/index.d.ts not found — run `pnpm build:native` first"); - return true; - } - - let content = fs.readFileSync(napiDtsPath, "utf8"); - const importLine = 'import type { ContourId, PointId, AnchorId } from "@shift/types";'; - - if (content.includes(importLine)) { - console.info("✓ shift-node/index.d.ts already patched"); - return true; - } - - const marker = "/* auto-generated by NAPI-RS */"; - if (content.includes(marker)) { - content = content.replace(marker, marker + "\n\n" + importLine); - } else { - content = importLine + "\n\n" + content; - } - - fs.writeFileSync(napiDtsPath, content); - console.info("✅ Patched shift-node/index.d.ts with branded ID imports"); - return true; -} - -/** Normalize whitespace on every generated file (whether it needs an ID - * import or not). ts-rs emits trailing whitespace + no terminating newline - * — left untouched, pre-commit's trailing-whitespace + end-of-file-fixer - * hooks would re-flag every commit. */ -function normalizeAllGenerated(): void { - if (!fs.existsSync(GENERATED_DIR)) return; - for (const filename of fs.readdirSync(GENERATED_DIR)) { - if (!filename.endsWith(".ts")) continue; - if (filename in FILE_IMPORTS) continue; // patchFile already normalised - const filePath = path.join(GENERATED_DIR, filename); - const original = fs.readFileSync(filePath, "utf8"); - const normalized = normalizeWhitespace(original); - if (original !== normalized) { - fs.writeFileSync(filePath, normalized); - console.info(`✅ Normalised whitespace in ${filename}`); - } - } -} - -function main(): void { - console.info("🔧 Patching generated types with branded ID imports...\n"); - - let success = true; - - for (const [filename, imports] of Object.entries(FILE_IMPORTS)) { - if (!patchFile(filename, imports)) { - success = false; - } - } - - normalizeAllGenerated(); - - if (!patchNapiDts()) { - success = false; - } - - console.info(""); - - if (success) { - console.info("✅ All generated types patched successfully!"); - } else { - console.error("❌ Some files could not be patched"); - process.exit(1); - } -} - -main(); diff --git a/scripts/watch.sh b/scripts/watch.sh index bedf27d9..096213e8 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -14,9 +14,9 @@ cleanup() { trap cleanup EXIT INT TERM cargo watch \ - -w "crates/shift-core/src/" \ - -w "crates/shift-node/src/" \ - -w "crates/shift-core/Cargo.toml" \ - -w "crates/shift-node/Cargo.toml" \ + -w "crates/shift-edit/src/" \ + -w "crates/shift-bridge/src/" \ + -w "crates/shift-edit/Cargo.toml" \ + -w "crates/shift-bridge/Cargo.toml" \ -w "Cargo.toml" \ -s "pnpm run build:native:debug && touch apps/desktop/src/main/main.ts" diff --git a/turbo.json b/turbo.json index 277e5ead..cd3f0abe 100644 --- a/turbo.json +++ b/turbo.json @@ -3,24 +3,20 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"], + "outputs": ["dist/**", "index.js", "index.d.ts", "*.node"], "inputs": ["src/**", "package.json", "tsconfig.json"] }, "build:native": { "inputs": ["crates/*/src/**", "crates/*/Cargo.toml", "Cargo.toml", "Cargo.lock"], "outputs": [ - "crates/shift-node/index.js", - "crates/shift-node/index.d.ts", - "crates/shift-node/*.node" + "crates/shift-bridge/index.js", + "crates/shift-bridge/index.d.ts", + "crates/shift-bridge/*.node" ] }, - "generate:types": { - "inputs": [ - "crates/shift-core/src/**", - "crates/shift-ir/src/**", - "scripts/patch-generated-types.ts" - ], - "outputs": ["packages/types/src/generated/*.ts"] + "generate:bridge-types": { + "inputs": ["../../crates/shift-bridge/index.d.ts", "../../scripts/generate-bridge-types.mjs"], + "outputs": ["src/bridge/generated.ts"] }, "dev": { "dependsOn": ["build:native"], @@ -28,7 +24,7 @@ "persistent": true }, "typecheck": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "^generate:bridge-types", "generate:bridge-types"], "outputs": [] }, "lint": { From f20d298756ebe0b034dada3e8e193d183843392e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 6 May 2026 17:55:59 +0100 Subject: [PATCH 08/13] refactor: rebuild glyph editing around source-aware geometry - introduce Glyph/GlyphSource/Source edit boundaries - move reusable glyph geometry into @shift/glyph-state - replace node position updates with source positions and edit drafts - remove snapping and clean up select/pen editing flows - update bridge APIs, clipboard handling, tests, and docs --- .gitignore | 1 + .prettierignore | 5 +- ROADMAP.md | 303 +++++- apps/desktop/package.json | 2 +- apps/desktop/src/renderer/src/app/App.tsx | 12 +- .../src/components/debug/DebugPanel.tsx | 5 +- .../src/components/editor/EditorView.tsx | 14 +- .../src/components/editor/GlyphFinder.tsx | 2 +- .../src/components/editor/RightSidebar.tsx | 2 +- .../src/components/editor/ToolsPane.tsx | 2 +- .../editor/sidebar-right/AnchorSection.tsx | 10 +- .../editor/sidebar-right/GlyphSection.tsx | 5 +- .../editor/sidebar-right/TransformSection.tsx | 2 +- .../src/components/home/GlyphGrid.tsx | 3 +- .../src/components/home/GlyphPreview.tsx | 38 +- .../src/components/text/HiddenTextInput.tsx | 2 +- .../src/components/variation/AxesPanel.tsx | 20 +- .../src/components/variation/Sources.tsx | 6 +- .../src/context/GlyphCatalogContext.tsx | 5 +- .../renderer/src/hooks/useActiveSourceId.ts | 23 +- .../renderer/src/hooks/useApplyVariation.ts | 15 - .../desktop/src/renderer/src/hooks/useAxes.ts | 2 +- .../renderer/src/hooks/useDesignLocation.ts | 12 + .../src/hooks/useGlyphSidebearings.ts | 2 +- .../renderer/src/hooks/useGlyphXAdvance.ts | 2 +- .../renderer/src/hooks/useSelectionBounds.ts | 2 +- .../src/renderer/src/hooks/useSignalEffect.ts | 2 +- .../src/renderer/src/hooks/useSources.ts | 7 +- .../src/hooks/useVariationLocation.ts | 15 - .../src/lib/clipboard/Clipboard.test.ts | 2 +- .../renderer/src/lib/clipboard/Clipboard.ts | 215 +--- .../src/lib/clipboard/ClipboardSelection.ts | 130 +++ .../clipboard/importers/SvgImporter.test.ts | 16 +- .../lib/clipboard/importers/SvgImporter.ts | 33 +- .../src/renderer/src/lib/clipboard/index.ts | 11 +- .../src/renderer/src/lib/clipboard/types.ts | 54 +- .../clipboard/ClipboardCommands.test.ts | 255 ++--- .../commands/clipboard/ClipboardCommands.ts | 69 +- .../renderer/src/lib/commands/core/Command.ts | 9 +- .../lib/commands/core/CommandHistory.test.ts | 465 +++------ .../src/lib/commands/core/CommandHistory.ts | 15 +- .../renderer/src/lib/commands/docs/DOCS.md | 30 +- .../src/renderer/src/lib/commands/index.ts | 7 +- .../primitives/BezierCommands.test.ts | 380 +++---- .../lib/commands/primitives/BezierCommands.ts | 88 +- .../primitives/BooleanOperationCommand.ts | 42 + .../commands/primitives/PointCommands.test.ts | 96 +- .../lib/commands/primitives/PointCommands.ts | 31 +- .../SetNodePositionsCommand.test.ts | 149 --- .../primitives/SetNodePositionsCommand.ts | 177 ---- .../SetSourcePositionsCommand.test.ts | 75 ++ .../primitives/SetSourcePositionsCommand.ts | 41 + .../commands/primitives/ShapeCommands.test.ts | 44 + .../lib/commands/primitives/ShapeCommands.ts | 62 ++ .../primitives/SidebearingCommands.test.ts | 97 +- .../primitives/SidebearingCommands.ts | 24 +- .../commands/primitives/SnapshotCommand.ts | 36 - .../src/lib/commands/primitives/index.ts | 8 +- .../renderer/src/lib/commands/testUtils.ts | 67 ++ .../commands/transform/AlignmentCommands.ts | 19 +- .../transform/TransformCommands.test.ts | 229 ++-- .../commands/transform/TransformCommands.ts | 25 +- .../renderer/src/lib/editor/Editor.test.ts | 56 +- .../src/renderer/src/lib/editor/Editor.ts | 619 ++++++----- .../src/lib/editor/SourceEditDraft.test.ts | 77 ++ .../src/lib/editor/SourceEditDraft.ts | 84 ++ .../src/renderer/src/lib/editor/docs/DOCS.md | 48 +- .../src/renderer/src/lib/editor/draft.test.ts | 84 -- .../src/lib/editor/hit/boundingBox.test.ts | 2 +- .../src/lib/editor/hit/boundingBox.ts | 2 +- .../editor/managers/EdgePanManager.test.ts | 2 +- .../src/lib/editor/managers/EdgePanManager.ts | 2 +- .../src/lib/editor/managers/HoverManager.ts | 2 +- .../lib/editor/managers/SnapManager.test.ts | 98 -- .../src/lib/editor/managers/SnapManager.ts | 181 ---- .../editor/managers/ViewportManager.test.ts | 2 +- .../lib/editor/managers/ViewportManager.ts | 5 +- .../renderer/src/lib/editor/managers/index.ts | 1 - .../src/lib/editor/rendering/Canvas.ts | 2 +- .../src/lib/editor/rendering/FpsMonitor.ts | 2 +- .../src/lib/editor/rendering/Handles.ts | 13 +- .../renderer/src/lib/editor/rendering/Text.ts | 37 +- .../src/lib/editor/rendering/Theme.ts | 2 - .../src/lib/editor/rendering/Viewport.ts | 2 +- .../editor/rendering/gpu/classifyHandles.ts | 12 +- .../src/lib/editor/rendering/gpu/types.ts | 2 +- .../editor/rendering/indicators/Anchors.ts | 10 +- .../rendering/indicators/BoundingBox.ts | 2 +- .../rendering/indicators/ControlLines.ts | 6 +- .../rendering/indicators/DebugOverlays.ts | 2 +- .../editor/rendering/indicators/Segments.ts | 2 +- .../editor/rendering/indicators/SnapLines.ts | 38 - .../rendering/indicators/handleDrawing.ts | 2 +- .../lib/editor/rendering/indicators/index.ts | 1 - .../lib/editor/snapping/SnapPipelineRunner.ts | 73 -- .../renderer/src/lib/editor/snapping/steps.ts | 174 ---- .../renderer/src/lib/editor/snapping/types.ts | 156 --- .../renderer/src/lib/editor/variation.test.ts | 79 +- .../src/lib/interpolation/interpolate.test.ts | 2 +- .../src/lib/interpolation/interpolate.ts | 10 +- .../src/renderer/src/lib/model/Font.test.ts | 146 +++ .../src/renderer/src/lib/model/Font.ts | 386 +++++-- .../src/renderer/src/lib/model/Glyph.test.ts | 210 ++++ .../src/renderer/src/lib/model/Glyph.ts | 976 ++++++++---------- .../renderer/src/lib/model/GlyphOutline.ts | 223 ++++ .../renderer/src/lib/model/GlyphView.test.ts | 124 --- .../src/renderer/src/lib/model/GlyphView.ts | 359 ------- .../src/lib/model/SourcePositionList.ts | 110 ++ .../src/renderer/src/lib/model/glyph.test.ts | 132 --- .../lib/{reactive => signals}/docs/DOCS.md | 2 +- .../src/lib/{reactive => signals}/index.ts | 0 .../lib/{reactive => signals}/signal.test.ts | 8 +- .../src/lib/{reactive => signals}/signal.ts | 0 .../lib/{reactive => signals}/useSignal.ts | 0 .../renderer/src/lib/state/ShiftState.test.ts | 2 +- .../src/renderer/src/lib/state/ShiftState.ts | 2 +- .../src/renderer/src/lib/text/TextBuffer.ts | 2 +- .../renderer/src/lib/text/TextInteraction.ts | 2 +- .../src/renderer/src/lib/text/TextRun.ts | 21 +- .../src/renderer/src/lib/text/TextRuns.ts | 18 +- .../src/lib/text/layout/Caret.test.ts | 6 +- .../src/lib/text/layout/Positioner.test.ts | 22 +- .../src/lib/text/layout/Positioner.ts | 47 +- .../src/lib/text/layout/TextLayout.test.ts | 16 +- .../src/lib/text/layout/TextLayout.ts | 9 +- .../renderer/src/lib/text/layout/testUtils.ts | 13 +- .../src/renderer/src/lib/text/layout/types.ts | 4 +- .../renderer/src/lib/tools/core/BaseTool.ts | 6 +- .../renderer/src/lib/tools/core/Behavior.ts | 2 +- .../src/lib/tools/core/GestureDetector.ts | 2 +- .../core/StateDiagram.compliance.test.ts | 248 ----- .../src/lib/tools/core/StateDiagram.test.ts | 94 -- .../src/lib/tools/core/StateDiagram.ts | 9 - .../src/lib/tools/core/ToolManager.ts | 2 +- .../src/renderer/src/lib/tools/core/index.ts | 8 +- .../lib/tools/core/stateDiagramToMermaid.ts | 17 - .../src/renderer/src/lib/tools/docs/DOCS.md | 17 +- .../src/renderer/src/lib/tools/hand/Hand.ts | 1 + .../src/renderer/src/lib/tools/hand/types.ts | 2 +- .../src/renderer/src/lib/tools/pen/Pen.ts | 20 +- .../tools/pen/behaviors/CancelBehaviour.ts | 3 +- .../pen/behaviors/DragHandlesBehaviour.ts | 144 +-- .../tools/pen/behaviors/PenDownBehaviour.ts | 27 +- .../src/lib/tools/pen/behaviors/PenStroke.ts | 135 +++ .../src/renderer/src/lib/tools/pen/types.ts | 4 +- .../renderer/src/lib/tools/select/Select.ts | 1 + .../lib/tools/select/behaviors/BendCurve.ts | 14 +- .../src/lib/tools/select/behaviors/Marquee.ts | 3 +- .../src/lib/tools/select/behaviors/Resize.ts | 68 +- .../src/lib/tools/select/behaviors/Rotate.ts | 88 +- .../tools/select/behaviors/ToggleSmooth.ts | 6 +- .../lib/tools/select/behaviors/Translate.ts | 417 ++++---- .../renderer/src/lib/tools/select/types.ts | 4 +- .../renderer/src/lib/tools/select/utils.ts | 2 +- .../src/renderer/src/lib/tools/shape/Shape.ts | 24 +- .../src/renderer/src/lib/tools/shape/types.ts | 2 +- .../src/renderer/src/lib/tools/text/Text.ts | 4 +- .../src/lib/transform/Alignment.test.ts | 34 +- .../renderer/src/lib/transform/Alignment.ts | 11 +- .../src/lib/transform/PointPosition.ts | 12 - .../renderer/src/lib/transform/Transform.ts | 40 +- .../src/renderer/src/lib/transform/anchor.ts | 2 +- .../renderer/src/lib/transform/docs/DOCS.md | 8 +- .../renderer/src/lib/variation/location.ts | 56 + .../src/renderer/src/perf/drawing.bench.ts | 2 +- .../renderer/src/perf/interaction.bench.ts | 10 +- .../renderer/src/perf/napiBoundary.bench.ts | 32 +- .../src/perf/pointManipulation.bench.ts | 32 +- .../src/renderer/src/perf/rendering.bench.ts | 2 +- .../src/renderer/src/persistence/kernel.ts | 2 +- .../src/renderer/src/persistence/types.ts | 2 - .../src/renderer/src/store/glyphInfo.ts | 8 - apps/desktop/src/renderer/src/store/store.ts | 19 +- .../src/renderer/src/testing/TestEditor.ts | 47 +- .../src/renderer/src/testing/coordinates.ts | 2 +- .../src/renderer/src/testing/engine.ts | 18 - .../desktop/src/renderer/src/testing/index.ts | 2 +- .../src/renderer/src/testing/pointMark.ts | 29 +- .../src/types/{engine.ts => bridge.ts} | 9 +- .../src/renderer/src/types/coordinates.ts | 2 +- apps/desktop/src/renderer/src/types/draft.ts | 9 - apps/desktop/src/renderer/src/types/editor.ts | 9 - .../src/renderer/src/types/hitResult.ts | 6 +- .../src/renderer/src/types/indicator.ts | 20 +- .../src/renderer/src/types/positionUpdate.ts | 14 - .../src/renderer/src/types/segments.ts | 58 -- .../src/renderer/src/types/selection.test.ts | 2 +- .../src/renderer/src/types/selection.ts | 5 +- .../src/renderer/src/types/transform.ts | 2 +- .../src/renderer/src/types/variation.ts | 4 + .../desktop/src/renderer/src/views/Editor.tsx | 2 +- .../src/renderer/src/views/FontInfo.tsx | 4 +- .../src/renderer/src/views/Landing.tsx | 14 +- apps/desktop/tsconfig.json | 1 + apps/desktop/vite.renderer.config.ts | 4 +- crates/shift-backends/tests/loading.rs | 24 +- crates/shift-backends/tests/round_trip/ufo.rs | 14 +- crates/shift-bridge/__test__/index.spec.mjs | 29 +- crates/shift-bridge/index.d.ts | 32 +- crates/shift-bridge/src/bridge.rs | 331 +++++- crates/shift-bridge/src/input.rs | 6 +- crates/shift-edit/src/interpolation.rs | 144 ++- docs/architecture/index.md | 58 +- packages/font/docs/DOCS.md | 87 -- packages/font/src/Contour.test.ts | 101 -- packages/font/src/Contour.ts | 197 ---- packages/font/src/Glyph.test.ts | 139 --- packages/font/src/Glyph.ts | 93 -- packages/font/src/GlyphEquality.test.ts | 89 -- packages/font/src/GlyphEquality.ts | 82 -- packages/font/src/index.ts | 15 - packages/geo/docs/DOCS.md | 16 +- packages/geo/src/Mat.ts | 13 +- packages/geo/src/Vec2.test.ts | 171 --- packages/geo/src/Vec2.ts | 128 --- packages/geo/src/index.ts | 2 +- packages/geo/src/types.ts | 12 +- packages/glyph-state/docs/DOCS.md | 50 + packages/{font => glyph-state}/package.json | 4 +- packages/glyph-state/src/Anchor.ts | 47 + packages/glyph-state/src/Component.ts | 80 ++ packages/glyph-state/src/Contour.test.ts | 68 ++ packages/glyph-state/src/Contour.ts | 157 +++ .../src/GlyphGeometry.test.ts | 24 +- .../src/GlyphGeometry.ts | 14 +- .../glyph-state/src/GlyphStateGeometry.ts | 184 ++++ .../glyph-state/src}/Segment.test.ts | 3 +- .../glyph-state/src}/Segment.ts | 61 +- packages/glyph-state/src/index.ts | 35 + packages/{font => glyph-state}/tsconfig.json | 0 .../{font => glyph-state}/vitest.config.ts | 0 packages/rules/docs/DOCS.md | 8 +- packages/rules/package.json | 1 - packages/rules/src/actions.ts | 47 +- packages/rules/src/index.ts | 3 +- packages/rules/src/matcher.ts | 38 +- packages/rules/src/rules.test.ts | 30 +- packages/rules/src/types.ts | 11 +- packages/types/docs/DOCS.md | 5 +- packages/types/src/bridge/generated.ts | 265 ++--- packages/types/src/bridge/index.ts | 3 + packages/types/src/index.ts | 3 + packages/validation/docs/DOCS.md | 28 +- packages/validation/src/ValidateClipboard.ts | 6 +- .../validation/src/ValidateSnapshot.test.ts | 332 ------ packages/validation/src/ValidateSnapshot.ts | 187 ---- packages/validation/src/index.ts | 5 - packages/validation/src/persistence.test.ts | 31 +- packages/validation/src/persistence.ts | 15 - packages/validation/src/types.ts | 4 - pnpm-lock.yaml | 41 +- scripts/context-drift-check.py | 8 +- scripts/generate-bridge-types.mjs | 21 +- scripts/oxlint/shift-plugin.mjs | 10 +- 254 files changed, 6061 insertions(+), 7716 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/hooks/useApplyVariation.ts create mode 100644 apps/desktop/src/renderer/src/hooks/useDesignLocation.ts delete mode 100644 apps/desktop/src/renderer/src/hooks/useVariationLocation.ts create mode 100644 apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SnapshotCommand.ts create mode 100644 apps/desktop/src/renderer/src/lib/commands/testUtils.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/draft.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/indicators/SnapLines.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/snapping/SnapPipelineRunner.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/snapping/steps.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/snapping/types.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/Font.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/Glyph.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts delete mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphView.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/SourcePositionList.ts delete mode 100644 apps/desktop/src/renderer/src/lib/model/glyph.test.ts rename apps/desktop/src/renderer/src/lib/{reactive => signals}/docs/DOCS.md (94%) rename apps/desktop/src/renderer/src/lib/{reactive => signals}/index.ts (100%) rename apps/desktop/src/renderer/src/lib/{reactive => signals}/signal.test.ts (97%) rename apps/desktop/src/renderer/src/lib/{reactive => signals}/signal.ts (100%) rename apps/desktop/src/renderer/src/lib/{reactive => signals}/useSignal.ts (100%) delete mode 100644 apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/core/stateDiagramToMermaid.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenStroke.ts delete mode 100644 apps/desktop/src/renderer/src/lib/transform/PointPosition.ts create mode 100644 apps/desktop/src/renderer/src/lib/variation/location.ts delete mode 100644 apps/desktop/src/renderer/src/store/glyphInfo.ts delete mode 100644 apps/desktop/src/renderer/src/testing/engine.ts rename apps/desktop/src/renderer/src/types/{engine.ts => bridge.ts} (50%) delete mode 100644 apps/desktop/src/renderer/src/types/draft.ts delete mode 100644 apps/desktop/src/renderer/src/types/positionUpdate.ts delete mode 100644 apps/desktop/src/renderer/src/types/segments.ts create mode 100644 apps/desktop/src/renderer/src/types/variation.ts delete mode 100644 packages/font/docs/DOCS.md delete mode 100644 packages/font/src/Contour.test.ts delete mode 100644 packages/font/src/Contour.ts delete mode 100644 packages/font/src/Glyph.test.ts delete mode 100644 packages/font/src/Glyph.ts delete mode 100644 packages/font/src/GlyphEquality.test.ts delete mode 100644 packages/font/src/GlyphEquality.ts delete mode 100644 packages/font/src/index.ts create mode 100644 packages/glyph-state/docs/DOCS.md rename packages/{font => glyph-state}/package.json (80%) create mode 100644 packages/glyph-state/src/Anchor.ts create mode 100644 packages/glyph-state/src/Component.ts create mode 100644 packages/glyph-state/src/Contour.test.ts create mode 100644 packages/glyph-state/src/Contour.ts rename packages/{font => glyph-state}/src/GlyphGeometry.test.ts (87%) rename packages/{font => glyph-state}/src/GlyphGeometry.ts (90%) create mode 100644 packages/glyph-state/src/GlyphStateGeometry.ts rename {apps/desktop/src/renderer/src/lib/model => packages/glyph-state/src}/Segment.test.ts (97%) rename {apps/desktop/src/renderer/src/lib/model => packages/glyph-state/src}/Segment.ts (79%) create mode 100644 packages/glyph-state/src/index.ts rename packages/{font => glyph-state}/tsconfig.json (100%) rename packages/{font => glyph-state}/vitest.config.ts (100%) delete mode 100644 packages/validation/src/ValidateSnapshot.test.ts delete mode 100644 packages/validation/src/ValidateSnapshot.ts diff --git a/.gitignore b/.gitignore index 14b98712..1da1c171 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +.pnpm-store/ # TypeScript v1 declaration files typings/ diff --git a/.prettierignore b/.prettierignore index a8a5b92b..50329e61 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,7 +6,8 @@ out *.min.css pnpm-lock.yaml packages/types/src/generated +packages/types/src/bridge/generated.ts packages/types/__fixtures__ -crates/shift-node/index.js -crates/shift-node/index.d.ts +crates/shift-bridge/index.js +crates/shift-bridge/index.d.ts **/vendor diff --git a/ROADMAP.md b/ROADMAP.md index c439bc36..d8e52b46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,4 +1,222 @@ -# Shift Feature Roadmap +# Shift Roadmap + +This roadmap has two layers: + +- **Release roadmap:** the default order of work for shipping usable versions. +- **Feature inventory:** the broader backlog of implemented, planned, and experimental work. + +The release roadmap wins when priorities conflict. Exploration is still useful, but every release should have one clear promise and a small set of acceptance tests. + +## Current Stage + +Current repo state: `0.0.1-dev` / pre-alpha source development. + +Current capability level: roughly `0.2.x-alpha` feature maturity. Core editing work is significantly ahead of release infrastructure, so the first public binary does not need to start at `0.1.0` if the changelog honestly explains what already exists. + +Recommended first public binary: `0.2.0-alpha.1`. + +Why: + +- Basic vector editing, selection, undo/redo, delete, clipboard, segment hover, snapping pieces, transform tools, glyph thumbnails/search, boolean operations, and some variable/text work already exist. +- Public release basics are not done yet: no release tags, no root changelog, no release workflow, no signed/notarized binary, no published artifacts, and version files disagree. + +## Release Roadmap + +### 0.2.0-alpha.1 — First Installable Editing Alpha + +Promise: a tester can install Shift, launch it, open a font, and try the existing core editor without building from source. + +Scope: + +- GitHub Release with release notes and checksums. +- Version files aligned. +- Root `CHANGELOG.md`. +- macOS artifact, signed and notarized if possible. +- Windows and Linux artifacts if CI can produce them without derailing the release. +- README and `shift.graphics` use the same alpha language. +- Known limitations documented prominently. +- Existing editor workflows are not blocked by obvious packaging/native-module issues. + +Acceptance tests: + +- Download the release artifact on a clean macOS machine. +- Launch the app without Gatekeeper workarounds, or clearly label the build as unsigned if signing is deferred. +- Open a UFO/TTF/OTF. +- Select a glyph, edit points, undo/redo, copy/paste, and close the app. + +### 0.3.0-alpha — Persistence And Export Alpha + +Promise: a tester can use Shift on a toy font and verify save/reopen/export behavior. + +Scope: + +- UFO save and Save As are reliable enough for alpha testing. +- Dirty state and close/quit save prompts are trustworthy. +- Reopen after save preserves expected glyph edits. +- TTF/OTF export path exists for simple fonts. +- Export errors are visible and actionable. +- Round-trip tests cover representative UFO edits. +- Data-loss risks are documented. + +Acceptance tests: + +- Open UFO, edit glyph, save, quit, reopen, verify edit. +- Save As to a new UFO and reopen it. +- Export a simple TTF/OTF and install or inspect it externally. +- Try saving a non-writable source format and verify Shift forces Save As. + +### 0.4.0-alpha — Glyph Workflow Alpha + +Promise: a tester can work across multiple glyphs without fighting navigation or glyph metadata. + +Scope: + +- Recent files are functional. +- Glyph grid supports the common navigation path. +- Glyph add, duplicate, delete, and rename basics. +- Unicode/name editing basics. +- Open Recent and core File/Glyph menu items. +- Keyboard navigation in the grid. +- Basic validation for empty/missing/problem glyphs if cheap. + +Acceptance tests: + +- Open a font, find a glyph by name/unicode/character, edit it, move to another glyph, return to the first. +- Add or duplicate a glyph and save/reopen. +- Rename or edit unicode metadata and verify the result survives save/reopen where supported. + +### 0.5.0-alpha — Drawing Workflow Depth + +Promise: contour editing feels useful beyond simple point movement. + +Scope: + +- Boolean operations are stabilized in the UI. +- Remove overlap or path direction cleanup, whichever is more valuable first. +- Shape tools for rectangle/ellipse if they support real glyph work. +- Better point/segment indicators: extrema, open endpoints, smooth tangent lines, or equivalent. +- Measurement/guides if they unblock precision work. +- Zoom to selection / center glyph. + +Acceptance tests: + +- Draw overlapping contours, run boolean/remove-overlap workflow, save/reopen. +- Build a simple glyph from shapes and manual point edits. +- Use precision aids to align or measure a contour without guessing. + +### 0.6.0-alpha — Components And Accents Alpha + +Promise: Shift can represent and edit composite glyph workflows at an alpha level. + +Scope: + +- Component data model and snapshots. +- Add component to glyph. +- Move/transform component. +- Render component bounds/ghosting. +- Decompose component. +- Basic anchors. +- Simple accented glyph generation path. + +Acceptance tests: + +- Build an accented glyph from a base and mark component. +- Move/transform a component and save/reopen. +- Decompose a component and continue editing outlines. + +### 0.7.0-alpha — Variable Font Alpha + +Promise: Shift can inspect and test variable font/designspace workflows, even if editing is incomplete. + +Scope: + +- Designspace loading is user-facing. +- Master switching. +- Add/remove or copy master workflow if feasible. +- Interpolation preview is reliable enough for tester feedback. +- Named instances. +- Instance export for simple cases. +- Compatibility errors are understandable. + +Acceptance tests: + +- Open designspace, switch masters, preview interpolation. +- Detect incompatible glyphs and show a useful message. +- Export or generate a simple instance. + +### 0.8.0-alpha — Spacing And Proofing Alpha + +Promise: a tester can evaluate glyphs in text context. + +Scope: + +- Sidebearing handles or numeric sidebearing editing. +- Spacing strings and presets. +- Preview/proofing panel. +- Waterfall view. +- Basic kerning preview or editing if ready. +- HarfBuzz shaping preview if the plumbing is ready. + +Acceptance tests: + +- Edit spacing for a glyph while viewing it in context. +- Save/reopen spacing changes. +- Preview a sample string at multiple sizes. + +### 0.9.0-beta.1 — Beta Candidate + +Promise: a type designer can complete a small real task end-to-end, and the beta line is primarily about fixing bugs. + +Scope: + +- Feature freeze for the 1.0 core workflow. +- Packaging works on the supported platforms. +- macOS signing/notarization is mandatory. +- Windows/Linux packaging status is clearly documented. +- Documentation for install, open, edit, save, export, and known limitations. +- Crash/diagnostic story or at least useful error reporting. +- File-format/data-loss risks have explicit tests. + +Acceptance tests: + +- Complete a small real project from install to exported font. +- Verify clean install on each supported platform. +- Verify release notes, changelog, and website match the actual release state. + +### 1.0.0 — Stable + +Promise: Shift is a production-quality font editor for the documented core workflow. + +Scope: + +- Main workflow is dependable: install, open/create, edit, save/reopen, export. +- Documentation is sufficient for non-contributors. +- Compatibility and file-format expectations are explicit. +- Update path exists or the absence of auto-update is intentional and documented. +- Known critical data-loss issues are fixed. + +## Priority Rules + +Use these rules when deciding what to work on next: + +1. If the current milestone has a broken acceptance test, fix that before adding unrelated feature surface. +2. Prefer work that completes an end-to-end workflow over work that adds isolated capability. +3. Keep experimental work behind the current release promise unless it directly reduces release risk. +4. Patch releases fix regressions only; minor releases add a new workflow promise. +5. Beta means feature freeze for the beta line, not a larger feature bucket. + +## Flexible Exploration Backlog + +These are allowed to jump around when energy is high, but they should not silently become release blockers: + +- Components and accents. +- Variable fonts. +- Spacing and kerning. +- OpenType feature editing. +- Scripting. +- AI/MCP integration. +- Collaboration. +- Advanced rendering/performance work. ## Current Implementation Status @@ -687,54 +905,67 @@ interface ShiftScriptContext { ## 📊 Version Milestones -### v0.1 - Alpha (MVP) +The authoritative milestone plan is the release roadmap at the top of this file. This section is a compact index. + +### v0.2-alpha — First Installable Editing Alpha + +- Release infrastructure and installable binaries. +- Existing core vector editing exposed to testers. +- Clear alpha limitations. + +### v0.3-alpha — Persistence And Export Alpha + +- Save/reopen loop. +- Save As and dirty state. +- Basic TTF/OTF export. +- Round-trip tests. + +### v0.4-alpha — Glyph Workflow Alpha -- Basic bezier drawing ✓ -- Point selection and manipulation ✓ -- Undo/redo ✓ -- Delete points ✓ -- Open/save UFO files -- Export to TTF +- Recent files. +- Better glyph grid navigation. +- Glyph add/duplicate/delete/rename. +- Unicode/name editing basics. -### v0.2 - Beta +### v0.5-alpha — Drawing Workflow Depth -- Full editing toolkit (copy/paste ✓, snapping) -- Segment hover/highlighting -- Grid panel improvements (thumbnails, search) -- Glyph add/delete/rename +- Stabilized boolean/path operations. +- Shape and precision workflow improvements. +- Better contour indicators and validation basics. -### v0.3 - Components +### v0.6-alpha — Components And Accents Alpha -- Component system -- Anchors -- Accented glyph generation +- Component data model. +- Component editing/rendering/decomposition. +- Anchors. +- Accented glyph generation basics. -### v0.4 - Variable Fonts +### v0.7-alpha — Variable Font Alpha -- Designspace support -- Master switching -- Interpolation preview +- User-facing designspace workflow. +- Master switching. +- Interpolation preview. +- Named instances and simple instance export. -### v0.5 - Professional +### v0.8-alpha — Spacing And Proofing Alpha -- Full menu system -- Preferences -- Incremental compilation -- HarfBuzz preview +- Sidebearing editing. +- Spacing strings. +- Preview/proofing panel. +- Waterfall view. -### v1.0 - Stable Release +### v0.9-beta — Beta Candidate -- Production ready -- Full documentation -- Signed distributions -- Auto-updates -- Scripting v1 +- Feature freeze for the 1.0 core workflow. +- Cross-platform packaging. +- Documentation. +- File-format and data-loss hardening. -### Post-1.0 +### v1.0 — Stable Release -- Collaboration features -- AI integration -- Plugin ecosystem +- Production-quality documented core workflow. +- Signed distributions. +- Clear compatibility and update posture. --- diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1da262ff..9a22ac1b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -53,7 +53,7 @@ "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", "@shift/bridge": "workspace:*", - "@shift/font": "workspace:*", + "@shift/glyph-state": "workspace:*", "@shift/geo": "workspace:*", "@shift/glyph-info": "workspace:*", "@shift/rules": "workspace:*", diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index 1c1d72db..8ebc92be 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -51,18 +51,20 @@ export const App = () => { try { editor.loadFont(filePath); - editor.updateMetricsFromFont(); + setFilePath(filePath); clearDirty(); + documentPersistence.openDocument(filePath); didOpenFont = true; + if (source === "restore") { const unicode = parseEditorUnicodeFromHash(window.location.hash); if (unicode !== null) { - const glyphName = editor.font.glyphName(unicode); - const handle = { glyphName, unicode }; - editor.setGlyphHandle(handle); - editor.openGlyph(handle); + const handle = editor.font.glyphHandleForUnicode(unicode); + if (handle) { + editor.getGlyph(handle); + } } } else { navigateToHome(); diff --git a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx index 6a5a8b7a..f9a79d8d 100644 --- a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx +++ b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx @@ -2,7 +2,7 @@ import { useRef, useEffect } from "react"; import { useSignalText } from "@/hooks/useSignalText"; import { getEditor } from "@/store/store"; import { Separator } from "@shift/ui"; -import { effect } from "@/lib/reactive"; +import { effect } from "@/lib/signals"; function formatCoords(x: number, y: number): string { return `(${Math.round(x)}, ${Math.round(y)})`; @@ -39,7 +39,7 @@ export function DebugPanel() { const glyph = editor.glyph.value; if (!glyph) return "—"; - const snapshot = glyph.toSnapshot(); + const snapshot = glyph.toState(); const json = JSON.stringify(snapshot); const bytes = new Blob([json]).size; @@ -97,6 +97,7 @@ export function DebugPanel() { +

Coordinates

Mouse

diff --git a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx index 1629cdc9..c00f7247 100644 --- a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx +++ b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useRef, useState } from "react"; import { CanvasContextProvider } from "@/context/CanvasContext"; import { useDebugSafe } from "@/context/DebugContext"; -import { effect } from "@/lib/reactive/signal"; +import { effect } from "@/lib/signals/signal"; import { getEditor } from "@/store/store"; import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; @@ -32,17 +32,11 @@ export const EditorView: FC = ({ glyphId }) => { useEffect(() => { const parsed = Number.parseInt(glyphId, 16); const unicode = Number.isNaN(parsed) ? 0x41 : parsed; - const glyphName = editor.font.glyphName(unicode); + const handle = editor.font.glyphHandleForUnicode(unicode); + if (!handle) return undefined; const initEditor = () => { - const handle = { glyphName, unicode }; - editor.setGlyphHandle(handle); - editor.openGlyph(handle); - - // Update viewport with actual font metrics (UPM, descender, guides) - editor.updateMetricsFromFont(); - - editor.requestRedraw(); + editor.getGlyph(handle); }; initEditor(); diff --git a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx index 2386cfd8..0c88597f 100644 --- a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx +++ b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx @@ -9,9 +9,9 @@ import { Separator, } from "@shift/ui"; import { formatCodepointAsUPlus } from "@/lib/utils/unicode"; -import { getGlyphInfo } from "@/store/glyphInfo"; import type { SearchResult } from "@shift/glyph-info"; import { useFocusZone } from "@/context/FocusZoneContext"; +import { getGlyphInfo } from "@/store/store"; interface GlyphFinderProps { open: boolean; diff --git a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx index 3773b056..9fef32d2 100644 --- a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx @@ -4,7 +4,7 @@ import { TransformSection } from "./sidebar-right/TransformSection"; import { ScaleSection } from "./sidebar-right/ScaleSection"; import { TransformOriginProvider } from "@/context/TransformOriginContext"; import { getEditor } from "@/store/store"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; import { useSignalEffect } from "@/hooks/useSignalEffect"; import { GlyphSection } from "./sidebar-right/GlyphSection"; import { AnchorSection } from "./sidebar-right/AnchorSection"; diff --git a/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx b/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx index 6d9d8e49..44a1343a 100644 --- a/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx +++ b/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@shift/ui"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; import { getEditor } from "@/store/store"; import { SVG } from "@/types/common"; import type { ToolName } from "@/lib/tools/core"; diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx index 9a0eca14..b0be88eb 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx @@ -8,10 +8,14 @@ import type { AnchorId } from "@shift/types"; export const AnchorSection = () => { const editor = getEditor(); + const [singleAnchorId, setSingleAnchorId] = useState(null); + const [anchorName, setAnchorName] = useState(null); + const [anchorX, setAnchorX] = useState(0); const [anchorY, setAnchorY] = useState(0); + const xRef = useRef(null); const yRef = useRef(null); @@ -46,12 +50,12 @@ export const AnchorSection = () => { const handlePositionChange = (axis: "x" | "y", value: number) => { if (!singleAnchorId) return; - const glyph = editor.glyph.peek(); - if (!glyph) return; + const layer = editor.activeGlyphSource; + if (!layer) return; const nextX = axis === "x" ? value : anchorX; const nextY = axis === "y" ? value : anchorY; - glyph.setNodePositions([{ node: { kind: "anchor", id: singleAnchorId }, x: nextX, y: nextY }]); + layer.setPositions([{ kind: "anchor", id: singleAnchorId, x: nextX, y: nextY }]); editor.requestRedraw(); }; diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx index b6e65617..4ba213b0 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx @@ -2,9 +2,8 @@ import { formatCodepointAsUPlus } from "@/lib/utils/unicode"; import { SidebarSection } from "./SidebarSection"; import { EditableSidebarInput } from "./EditableSidebarInput"; import PlaceholderGlyph from "@/assets/sidebar-right/placeholder-glyph.svg"; -import { getEditor } from "@/store/store"; -import { useSignalState } from "@/lib/reactive"; -import { getGlyphInfo } from "@/store/glyphInfo"; +import { getEditor, getGlyphInfo } from "@/store/store"; +import { useSignalState } from "@/lib/signals"; import { useGlyphSidebearings } from "@/hooks/useGlyphSidebearings"; import { useGlyphXAdvance } from "@/hooks/useGlyphXAdvance"; diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx index 01a2ea9e..aa94ade0 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx @@ -5,7 +5,7 @@ import { IconButton } from "./IconButton"; import { useTransformOrigin } from "@/context/TransformOriginContext"; import { getEditor } from "@/store/store"; import { anchorToPoint } from "@/lib/transform/anchor"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; import { useSelectionBounds } from "@/hooks/useSelectionBounds"; import RotateIcon from "@/assets/sidebar-right/rotate.svg"; diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index ee9d6acf..dc2e48de 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -45,8 +45,7 @@ import { useNavigate } from "react-router-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; import { codepointToHex } from "@/lib/utils/unicode"; import { CELL_HEIGHT, GlyphPreview } from "@/components/home/GlyphPreview"; -import { getGlyphInfo } from "@/store/glyphInfo"; -import { getEditor } from "@/store/store"; +import { getEditor, getGlyphInfo } from "@/store/store"; import { useGlyphCatalog } from "@/context/GlyphCatalogContext"; import { Button } from "@shift/ui"; diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 47351fc2..182e2aaf 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -1,7 +1,8 @@ import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; -import type { GlyphView } from "@/lib/model/GlyphView"; -import { useSignalState } from "@/lib/reactive"; +import type { Glyph } from "@/lib/model/Glyph"; +import { useSignalState } from "@/lib/signals"; +import { getEditor } from "@/store/store"; export const CELL_HEIGHT = 75; @@ -9,11 +10,7 @@ export const MARGIN_TOP_RATIO = 0.2; export const MARGIN_BOTTOM_RATIO = 0.05; export const MARGIN_SIDE_RATIO = 0; -export function glyphPreviewViewBox(metrics: FontMetrics | null, advance: number | null): string { - if (!metrics) { - return "0 -800 1000 1000"; - } - +export function glyphPreviewViewBox(metrics: FontMetrics, advance: number | null): string { const upm = metrics.unitsPerEm; const marginTop = upm * MARGIN_TOP_RATIO; const marginBottom = upm * MARGIN_BOTTOM_RATIO; @@ -33,11 +30,11 @@ export function computeViewBoxHeight(metrics: FontMetrics): number { } export function computeCellWidth( - metrics: FontMetrics | null, + metrics: FontMetrics, advance: number | null, cellHeight: number, ): number { - if (!metrics || advance === null) { + if (advance === null) { return cellHeight; } @@ -53,9 +50,13 @@ interface GlyphPreviewProps { } export function GlyphPreview({ unicode, font, height = CELL_HEIGHT }: GlyphPreviewProps) { - const name = font.nameForUnicode(unicode); - const glyph = name ? font.glyph(name) : null; + const editor = getEditor(); + + const handle = font.glyphHandleForUnicode(unicode); + const source = font.sourceAtOrDefault(editor.designLocation); + if (!source || !handle) return null; + const glyph = font.glyph(handle); if (!glyph) { return ; } @@ -72,11 +73,16 @@ function GlyphCell({ unicode: number; font: Font; height: number; - glyph: GlyphView; + glyph: Glyph; }) { - const svgPath = useSignalState(glyph.$svgPath); - const advance = useSignalState(glyph.$advance); - const fontMetrics = font.getMetrics(); + const editor = getEditor(); + const outline = glyph.outline(editor.$designLocation); + + const svgPath = useSignalState(outline.$svgPath); + const advance = useSignalState(glyph.$xAdvance); + + const fontMetrics = font.metrics; + const cellWidth = computeCellWidth(fontMetrics, advance, height); const containerStyle = { width: cellWidth, height }; @@ -114,7 +120,7 @@ function FallbackCell({ height: number; advance: number | null; }) { - const cellWidth = computeCellWidth(font.getMetrics(), advance, height); + const cellWidth = computeCellWidth(font.metrics, advance, height); return (
{ const axes = useAxes(); - const [location] = useVariationLocation(); - const apply = useApplyVariation(); + const [location, setDesignLocation] = useDesignLocation(); if (axes.length === 0) return null; - const onAxisChange = (tag: string, value: number) => apply({ ...location, [tag]: value }); + const onAxisChange = (axis: Axis, value: number) => { + const nextLocation = withAxisValue(location, axis, value); + setDesignLocation(nextLocation); + }; return (
@@ -24,14 +26,14 @@ export const AxesPanel = () => {
onAxisChange(axis.tag, value)} + onValueChange={(value) => onAxisChange(axis, value)} /> onAxisChange(axis.tag, value)} + value={axisValue(location, axis)} + onChange={(value) => onAxisChange(axis, value)} />
diff --git a/apps/desktop/src/renderer/src/components/variation/Sources.tsx b/apps/desktop/src/renderer/src/components/variation/Sources.tsx index 02bc2455..6638234f 100644 --- a/apps/desktop/src/renderer/src/components/variation/Sources.tsx +++ b/apps/desktop/src/renderer/src/components/variation/Sources.tsx @@ -1,12 +1,12 @@ import { Button } from "@shift/ui"; import { useSources } from "@/hooks/useSources"; import { useActiveSourceId } from "@/hooks/useActiveSourceId"; -import { useApplyVariation } from "@/hooks/useApplyVariation"; +import { getEditor } from "@/store/store"; export const Sources = () => { const sources = useSources(); const activeSourceId = useActiveSourceId(); - const apply = useApplyVariation(); + const editor = getEditor(); if (sources.length === 0) return null; @@ -19,7 +19,7 @@ export const Sources = () => { key={s.id} type="button" isActive={s.id === activeSourceId} - onClick={() => apply({ ...s.location })} + onClick={() => editor.selectSource(s.id)} > {s.name} diff --git a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx index 5437b096..82713818 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx @@ -1,8 +1,7 @@ import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; import type { GlyphCategory, GlyphCategoryCatalog, GlyphCategorySummary } from "@shift/glyph-info"; -import { useSignalState } from "@/lib/reactive"; -import { getEditor } from "@/store/store"; -import { getGlyphInfo } from "@/store/glyphInfo"; +import { useSignalState } from "@/lib/signals"; +import { getEditor, getGlyphInfo } from "@/store/store"; import { ADOBE_LATIN_1 } from "@data/adobe-latin-1"; export interface GlyphCatalogState { diff --git a/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts b/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts index 7f3929e4..cb7b2a86 100644 --- a/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts +++ b/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts @@ -1,23 +1,6 @@ -import { useMemo } from "react"; -import { useAxes } from "./useAxes"; -import { useSources } from "./useSources"; -import { useVariationLocation } from "./useVariationLocation"; +import { getEditor } from "@/store/store"; +import { useSignalState } from "@/lib/signals"; -/** - * The id of the source whose location matches the current variation location, - * or null if none match (e.g. mid-scrub between masters). - */ export const useActiveSourceId = (): string | null => { - const axes = useAxes(); - const sources = useSources(); - const [location] = useVariationLocation(); - - return useMemo(() => { - for (const source of sources) { - if (axes.every((axis) => source.location[axis.tag] === location[axis.tag])) { - return source.id; - } - } - return null; - }, [axes, sources, location]); + return useSignalState(getEditor().$activeSourceId); }; diff --git a/apps/desktop/src/renderer/src/hooks/useApplyVariation.ts b/apps/desktop/src/renderer/src/hooks/useApplyVariation.ts deleted file mode 100644 index 37b99340..00000000 --- a/apps/desktop/src/renderer/src/hooks/useApplyVariation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback } from "react"; -import type { AxisLocation } from "@shift/types"; -import { getEditor } from "@/store/store"; - -/** - * Returns a callback that pushes the given variation `location` through the - * editor — sets the shared `$variationLocation` and applies interpolated - * values to the active editable Glyph. All bookkeeping (cached variation - * data, "what's the active glyph") lives on `Editor` so this hook stays a - * thin React→Editor adapter. - */ -export const useApplyVariation = (): ((next: AxisLocation) => void) => { - const editor = getEditor(); - return useCallback((next: AxisLocation) => editor.applyVariation(next), [editor]); -}; diff --git a/apps/desktop/src/renderer/src/hooks/useAxes.ts b/apps/desktop/src/renderer/src/hooks/useAxes.ts index 8f2f2407..1ee5e333 100644 --- a/apps/desktop/src/renderer/src/hooks/useAxes.ts +++ b/apps/desktop/src/renderer/src/hooks/useAxes.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import type { Axis } from "@shift/types"; import { getEditor } from "@/store/store"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; /** * Active variation axes, or empty array when the font is not variable. diff --git a/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts b/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts new file mode 100644 index 00000000..1048b7dc --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts @@ -0,0 +1,12 @@ +import { useCallback } from "react"; +import { getEditor } from "@/store/store"; +import { useSignalState } from "@/lib/signals"; +import type { AxisLocation } from "@/types/variation"; + +export const useDesignLocation = (): [AxisLocation, (next: AxisLocation) => void] => { + const editor = getEditor(); + const location = useSignalState(editor.$designLocation); + const setLocation = useCallback((next: AxisLocation) => editor.setDesignLocation(next), [editor]); + + return [location, setLocation]; +}; diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts index 4b6c8175..e2ff4e8c 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts @@ -1,6 +1,6 @@ import type { GlyphSidebearings } from "@/lib/model/Glyph"; import { getEditor } from "@/store/store"; -import { useSignalState, useSignalTrigger } from "@/lib/reactive"; +import { useSignalState, useSignalTrigger } from "@/lib/signals"; const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null }; diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts index a9d57332..f0e28a74 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts @@ -1,5 +1,5 @@ import { getEditor } from "@/store/store"; -import { useSignalState, useSignalTrigger } from "@/lib/reactive"; +import { useSignalState, useSignalTrigger } from "@/lib/signals"; /** * Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded. diff --git a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts index 85ea88ff..df315dd4 100644 --- a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts +++ b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts @@ -1,6 +1,6 @@ import type { Bounds } from "@shift/geo"; import { getEditor } from "@/store/store"; -import { useSignalState, useSignalTrigger } from "@/lib/reactive"; +import { useSignalState, useSignalTrigger } from "@/lib/signals"; /** * Current selection bounds (axis-aligned, point-based), live-updating. diff --git a/apps/desktop/src/renderer/src/hooks/useSignalEffect.ts b/apps/desktop/src/renderer/src/hooks/useSignalEffect.ts index 028f81a0..1b926b46 100644 --- a/apps/desktop/src/renderer/src/hooks/useSignalEffect.ts +++ b/apps/desktop/src/renderer/src/hooks/useSignalEffect.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { effect } from "@/lib/reactive"; +import { effect } from "@/lib/signals"; export function useSignalEffect(fn: () => void) { useEffect(() => { diff --git a/apps/desktop/src/renderer/src/hooks/useSources.ts b/apps/desktop/src/renderer/src/hooks/useSources.ts index 1b544890..d78342f4 100644 --- a/apps/desktop/src/renderer/src/hooks/useSources.ts +++ b/apps/desktop/src/renderer/src/hooks/useSources.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import type { Source } from "@shift/types"; import { getEditor } from "@/store/store"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; /** * Variation sources/masters, or empty array when the font is not variable. @@ -11,8 +11,5 @@ import { useSignalState } from "@/lib/reactive"; export const useSources = (): Source[] => { const font = getEditor().font; const fontLoaded = useSignalState(font.$loaded); - return useMemo( - () => (fontLoaded && font.isVariable() ? font.getSources() : []), - [fontLoaded, font], - ); + return useMemo(() => (fontLoaded && font.isVariable() ? font.sources : []), [fontLoaded, font]); }; diff --git a/apps/desktop/src/renderer/src/hooks/useVariationLocation.ts b/apps/desktop/src/renderer/src/hooks/useVariationLocation.ts deleted file mode 100644 index d6c30bab..00000000 --- a/apps/desktop/src/renderer/src/hooks/useVariationLocation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback } from "react"; -import type { AxisLocation } from "@shift/types"; -import { getEditor } from "@/store/store"; -import { useSignalState } from "@/lib/reactive"; - -/** - * Current variation location and a setter, backed by `font.$variationLocation`. - * Multiple components reading this share one source of truth — no prop drilling. - */ -export const useVariationLocation = (): [AxisLocation, (next: AxisLocation) => void] => { - const font = getEditor().font; - const location = useSignalState(font.$variationLocation); - const setLocation = useCallback((next: AxisLocation) => font.setVariationLocation(next), [font]); - return [location, setLocation]; -}; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts index d81fb6b4..f237f0ee 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts @@ -71,7 +71,7 @@ describe("Clipboard (via Editor)", () => { await editor.paste(); await editor.paste(); - const contours = editor.currentGlyph?.contours ?? []; + const contours = (editor.currentGlyph?.contours ?? []).filter((contour) => !contour.isEmpty); expect(contours).toHaveLength(3); // Each paste translates the original by DEFAULT_PASTE_OFFSET (20) * diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts index 07c3ec90..a01848ee 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts @@ -1,21 +1,14 @@ -import type { Contour, Point, PointId, Rect2D } from "@shift/types"; -import type { SegmentId } from "@/types/indicator"; -import type { Glyph } from "@/lib/model/Glyph"; -import type { Signal } from "@/lib/reactive/signal"; -import type { Selection } from "@/types/selection"; -import type { CommandHistory } from "@/lib/commands"; -import { CutCommand, PasteCommand } from "@/lib/commands"; -import { Contours } from "@shift/font"; +import type { Rect2D } from "@shift/geo"; import { Polygon } from "@shift/geo"; -import { Validate } from "@shift/validation"; import { ValidateClipboard } from "@shift/validation"; import type { SystemClipboard, ClipboardContent, ClipboardImporter, + ClipboardOffer, ClipboardPayload, - ContourContent, - PointContent, + ClipboardReadResult, + ClipboardWriteMetadata, } from "./types"; import { SvgImporter } from "./importers/SvgImporter"; @@ -32,13 +25,6 @@ const EMPTY_BOUNDS: Rect2D = { bottom: 0, }; -export interface ClipboardDeps { - readonly glyph: Signal; - readonly selection: Selection; - readonly commands: CommandHistory; - readonly clipboard: SystemClipboard; -} - interface ClipboardState { content: ClipboardContent | null; bounds: Rect2D | null; @@ -46,11 +32,12 @@ interface ClipboardState { } /** - * Clipboard — owns copy/cut/paste orchestration, content resolution, - * serialization, and external format importing. + * Clipboard owns OS clipboard IO, serialization, and external format parsing. + * It returns glyph content to callers; Editor decides how that content mutates + * the active source. */ export class Clipboard { - readonly #deps: ClipboardDeps; + readonly #system: SystemClipboard; readonly #importers: ClipboardImporter[] = []; #internalState: ClipboardState = { content: null, @@ -59,58 +46,14 @@ export class Clipboard { }; #pasteCount = 0; - constructor(deps: ClipboardDeps) { - this.#deps = deps; + constructor(system: SystemClipboard) { + this.#system = system; this.#importers.push(new SvgImporter()); } - async copy(): Promise { - const content = this.#resolveContent(); + async write(content: ClipboardContent, metadata: ClipboardWriteMetadata = {}): Promise { if (!content || content.contours.length === 0) return false; - const glyph = this.#deps.glyph.peek(); - return this.#write(content, glyph?.name); - } - - async cut(): Promise { - const content = this.#resolveContent(); - if (!content || content.contours.length === 0) return false; - - const glyph = this.#deps.glyph.peek(); - const written = await this.#write(content, glyph?.name); - if (!written) return false; - - const pointIds = [...this.#deps.selection.pointIds]; - this.#deps.commands.execute(new CutCommand(pointIds)); - this.#deps.selection.clear(); - return true; - } - - async paste(): Promise { - const state = await this.#read(); - if (!state.content || state.content.contours.length === 0) return; - - const offset = DEFAULT_PASTE_OFFSET * (this.#pasteCount + 1); - this.#pasteCount++; - const cmd = new PasteCommand(state.content, { offset: { x: offset, y: -offset } }); - this.#deps.commands.execute(cmd); - - if (cmd.createdPointIds.length > 0) { - this.#deps.selection.select(cmd.createdPointIds.map((id) => ({ kind: "point", id }))); - } - } - - #resolveContent(): ClipboardContent | null { - const glyph = this.#deps.glyph.peek(); - if (!glyph) return null; - return resolveClipboardContent( - glyph, - this.#deps.selection.pointIds, - this.#deps.selection.segmentIds, - ); - } - - async #write(content: ClipboardContent, sourceGlyph?: string): Promise { const bounds = Polygon.boundingRect(content.contours.flatMap((c) => c.points)) ?? EMPTY_BOUNDS; this.#internalState = { content, bounds, timestamp: Date.now() }; this.#pasteCount = 0; @@ -120,80 +63,67 @@ export class Clipboard { version: 1, format: "shift/glyph-data", content, - metadata: { bounds, timestamp: Date.now(), ...(sourceGlyph ? { sourceGlyph } : {}) }, + metadata: { + bounds, + timestamp: Date.now(), + sourceApp: "shift", + ...(metadata.sourceGlyph ? { sourceGlyph: metadata.sourceGlyph } : {}), + }, }; - this.#deps.clipboard.writeText(JSON.stringify(payload)); + + this.#system.writeText(JSON.stringify(payload)); + return true; } catch { return false; } } - async #read(): Promise<{ content: ClipboardContent | null }> { + async read(): Promise { try { - const text = this.#deps.clipboard.readText(); + const text = this.#system.readText(); + const offers = this.#offersFromText(text); const native = tryDeserialize(text); - if (native) return { content: native }; + if (native) return { kind: "glyph", content: native, source: "shift" }; for (const importer of this.#importers) { - if (importer.canImport(text)) { - const imported = importer.import(text); - if (imported) return { content: imported }; - } + const offer = importer.pick(offers); + if (!offer) continue; + + const imported = await importer.import(offer); + if (imported) return { kind: "glyph", content: imported, source: importer.id }; + } + + if (text.trim().length > 0) { + return { + kind: "unsupported", + offeredTypes: offers.map((offer) => offer.mimeType), + }; } } catch { - // Fall through + if (this.#internalState.content) { + return { kind: "glyph", content: this.#internalState.content, source: "shift" }; + } } - return this.#internalState; - } -} -/** Resolve selected points/segments into copyable contour content. */ -export function resolveClipboardContent( - glyph: Glyph, - pointIds: ReadonlySet, - segmentIds: ReadonlySet, -): ClipboardContent | null { - const allPointIds = new Set(pointIds); - for (const segId of segmentIds) { - const [id1, id2] = segId.split(":"); - allPointIds.add(id1 as PointId); - allPointIds.add(id2 as PointId); + return { kind: "empty" }; } - if (allPointIds.size === 0) return null; - - const contours: ContourContent[] = []; - - for (const contour of glyph.contours) { - const selectedIndices = new Set(); - - for (const [idx, point] of contour.points.entries()) { - if (allPointIds.has(point.id)) { - selectedIndices.add(idx); - } - } - - if (selectedIndices.size === 0) continue; - - if (selectedIndices.size === contour.points.length) { - contours.push({ - points: contour.points.map(toContent), - closed: contour.closed, - }); - } else { - const expanded = expandPartialSelection(contour, selectedIndices); - if (!Validate.hasValidAnchor(expanded)) continue; - contours.push({ points: expanded.map(toContent), closed: false }); - } + nextPasteOffset(): { x: number; y: number } { + const offset = DEFAULT_PASTE_OFFSET * (this.#pasteCount + 1); + this.#pasteCount++; + return { x: offset, y: -offset }; } - return contours.length > 0 ? { contours } : null; -} + /** @knipclassignore — public clipboard API for edit-session resets. */ + resetPasteOffset(): void { + this.#pasteCount = 0; + } -function toContent(point: Point): PointContent { - return { x: point.x, y: point.y, pointType: point.pointType, smooth: point.smooth }; + #offersFromText(text: string): readonly ClipboardOffer[] { + return text.length > 0 ? [{ mimeType: "text/plain", text }] : []; + } } function tryDeserialize(text: string): ClipboardContent | null { @@ -206,46 +136,3 @@ function tryDeserialize(text: string): ClipboardContent | null { return null; } } - -function expandPartialSelection(contour: Contour, selectedIndices: Set): readonly Point[] { - const expanded = new Set(selectedIndices); - - for (const idx of selectedIndices) { - const point = Contours.at(contour, idx, false); - if (!point) continue; - - if (Validate.isOnCurve(point)) { - expandForOnCurve(contour, idx, expanded); - } else { - expandForOffCurve(contour, idx, expanded); - } - } - - return [...expanded] - .sort((a, b) => a - b) - .map((idx) => Contours.at(contour, idx, false)) - .filter((p): p is Point => p !== null); -} - -function expandForOnCurve(contour: Contour, idx: number, expanded: Set): void { - const prev = Contours.at(contour, idx - 1, false); - if (prev && Validate.isOffCurve(prev)) { - expanded.add(idx - 1); - const prevPrev = Contours.at(contour, idx - 2, false); - if (prevPrev && Validate.isOffCurve(prevPrev)) expanded.add(idx - 2); - } - - const next = Contours.at(contour, idx + 1, false); - if (next && Validate.isOffCurve(next)) { - expanded.add(idx + 1); - const nextNext = Contours.at(contour, idx + 2, false); - if (nextNext && Validate.isOffCurve(nextNext)) expanded.add(idx + 2); - } -} - -function expandForOffCurve(contour: Contour, idx: number, expanded: Set): void { - const prev = Contours.at(contour, idx - 1, false); - if (prev) expanded.add(idx - 1); - const next = Contours.at(contour, idx + 1, false); - if (next) expanded.add(idx + 1); -} diff --git a/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts b/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts new file mode 100644 index 00000000..29479d79 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts @@ -0,0 +1,130 @@ +import type { PointId } from "@shift/types"; +import { Validate } from "@shift/validation"; +import type { SegmentId } from "@/types/indicator"; +import type { Contour, Point } from "@shift/glyph-state"; +import type { ClipboardContent, ContourContent, PointContent } from "./types"; + +export interface ClipboardContourSource { + readonly contours: readonly Contour[]; +} + +export interface ClipboardSelectionSource { + readonly pointIds: ReadonlySet; + readonly segmentIds: ReadonlySet; +} + +export class ClipboardSelection { + readonly #pointIds: ReadonlySet; + + private constructor(pointIds: ReadonlySet) { + this.#pointIds = pointIds; + } + + static fromSelection(selection: ClipboardSelectionSource): ClipboardSelection { + return ClipboardSelection.fromIds(selection.pointIds, selection.segmentIds); + } + + static fromIds( + pointIds: ReadonlySet, + segmentIds: ReadonlySet, + ): ClipboardSelection { + const resolved = new Set(pointIds); + + for (const segmentId of segmentIds) { + const [id1, id2] = segmentId.split(":"); + if (id1) resolved.add(id1 as PointId); + if (id2) resolved.add(id2 as PointId); + } + + return new ClipboardSelection(resolved); + } + + get pointIds(): readonly PointId[] { + return [...this.#pointIds]; + } + + contentFrom(source: ClipboardContourSource): ClipboardContent | null { + if (this.#pointIds.size === 0) return null; + + const contours: ContourContent[] = []; + + for (const contour of source.contours) { + const selectedIndices = this.#selectedIndices(contour); + if (selectedIndices.size === 0) continue; + + if (selectedIndices.size === contour.points.length) { + contours.push({ + points: contour.points.map(toContent), + closed: contour.closed, + }); + } else { + const expanded = expandPartialSelection(contour, selectedIndices); + if (!Validate.hasValidAnchor(expanded)) continue; + + contours.push({ points: expanded.map(toContent), closed: false }); + } + } + + if (contours.length === 0) return null; + return { contours }; + } + + #selectedIndices(contour: Contour): Set { + const indices = new Set(); + + for (const [idx, point] of contour.points.entries()) { + if (this.#pointIds.has(point.id)) { + indices.add(idx); + } + } + + return indices; + } +} + +function toContent(point: Point): PointContent { + return { x: point.x, y: point.y, pointType: point.pointType, smooth: point.smooth }; +} + +function expandPartialSelection(contour: Contour, selectedIndices: Set): readonly Point[] { + const expanded = new Set(selectedIndices); + + for (const idx of selectedIndices) { + const point = contour.pointAt(idx, false); + if (!point) continue; + + if (Validate.isOnCurve(point)) { + expandForOnCurve(contour, idx, expanded); + } else { + expandForOffCurve(contour, idx, expanded); + } + } + + return [...expanded] + .sort((a, b) => a - b) + .map((idx) => contour.pointAt(idx, false)) + .filter((p): p is Point => p !== null); +} + +function expandForOnCurve(contour: Contour, idx: number, expanded: Set): void { + const prev = contour.pointAt(idx - 1, false); + if (prev && Validate.isOffCurve(prev)) { + expanded.add(idx - 1); + const prevPrev = contour.pointAt(idx - 2, false); + if (prevPrev && Validate.isOffCurve(prevPrev)) expanded.add(idx - 2); + } + + const next = contour.pointAt(idx + 1, false); + if (next && Validate.isOffCurve(next)) { + expanded.add(idx + 1); + const nextNext = contour.pointAt(idx + 2, false); + if (nextNext && Validate.isOffCurve(nextNext)) expanded.add(idx + 2); + } +} + +function expandForOffCurve(contour: Contour, idx: number, expanded: Set): void { + const prev = contour.pointAt(idx - 1, false); + if (prev) expanded.add(idx - 1); + const next = contour.pointAt(idx + 1, false); + if (next) expanded.add(idx + 1); +} diff --git a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.test.ts b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.test.ts index 3ea3343a..f77d3357 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.test.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.test.ts @@ -11,27 +11,27 @@ describe("SvgImporter", () => { } return value; }; - const requireImport = (input: string) => requireValue(importer.import(input)); + const requireImport = (input: string) => requireValue(importer.importText(input)); - describe("canImport", () => { + describe("canImportText", () => { it("recognizes SVG elements", () => { - expect(importer.canImport('')).toBe(true); + expect(importer.canImportText('')).toBe(true); }); it("recognizes path elements", () => { - expect(importer.canImport('')).toBe(true); + expect(importer.canImportText('')).toBe(true); }); it("recognizes raw path data", () => { - expect(importer.canImport("M0,0 L100,100 Z")).toBe(true); + expect(importer.canImportText("M0,0 L100,100 Z")).toBe(true); }); it("rejects plain text", () => { - expect(importer.canImport("hello world")).toBe(false); + expect(importer.canImportText("hello world")).toBe(false); }); it("rejects JSON", () => { - expect(importer.canImport('{"format": "shift/glyph-data"}')).toBe(false); + expect(importer.canImportText('{"format": "shift/glyph-data"}')).toBe(false); }); }); @@ -117,7 +117,7 @@ describe("SvgImporter", () => { }); it("returns null for invalid input", () => { - const result = importer.import(""); + const result = importer.importText(""); expect(result).toBeNull(); }); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts index 0c91ec71..44ed1f8c 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts @@ -1,4 +1,10 @@ -import type { ClipboardContent, ClipboardImporter, ContourContent, PointContent } from "../types"; +import type { + ClipboardContent, + ClipboardImporter, + ClipboardOffer, + ContourContent, + PointContent, +} from "../types"; type PathCommand = { type: string; @@ -6,16 +12,24 @@ type PathCommand = { }; export class SvgImporter implements ClipboardImporter { - readonly name = "SVG"; + readonly id = "svg"; - canImport(text: string): boolean { - const trimmed = text.trim(); + pick(offers: readonly ClipboardOffer[]): ClipboardOffer | null { return ( - trimmed.includes(" offer.text !== undefined && this.#canImportText(offer.text)) ?? null ); } - import(text: string): ClipboardContent | null { + import(offer: ClipboardOffer): ClipboardContent | null { + if (offer.text === undefined) return null; + return this.importText(offer.text); + } + + canImportText(text: string): boolean { + return this.#canImportText(text); + } + + importText(text: string): ClipboardContent | null { const trimmed = text.trim(); const pathData = this.#extractPathData(trimmed); @@ -30,6 +44,13 @@ export class SvgImporter implements ClipboardImporter { return { contours }; } + #canImportText(text: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.includes("; +export type PointContent = { + x: number; + y: number; + pointType: PointType; + smooth: boolean; +}; /** A single contour as stored in the clipboard. */ export type ContourContent = { @@ -28,19 +34,42 @@ export type ClipboardPayload = { metadata: { bounds: Rect2D; sourceGlyph?: string; + sourceApp: "shift"; timestamp: number; }; }; /** - * Strategy for importing external clipboard text (e.g. SVG paths) into - * the editor's internal clipboard format. Register importers to support - * additional paste sources. + * One offered OS clipboard representation. The current Electron bridge only + * exposes text, but the shape leaves room for image bytes and richer MIME + * payloads without changing the editor paste API. + */ +export interface ClipboardOffer { + readonly mimeType: string; + readonly text?: string; + readonly bytes?: Uint8Array; +} + +export type ClipboardSource = "shift" | "svg" | "fontra" | "glyphs" | "image"; + +export type ClipboardReadResult = + | { kind: "empty" } + | { kind: "glyph"; content: ClipboardContent; source: ClipboardSource } + | { kind: "unsupported"; offeredTypes: readonly string[]; reason?: string }; + +export interface ClipboardWriteMetadata { + readonly sourceGlyph?: string; +} + +/** + * Strategy for importing external clipboard offers (e.g. SVG paths) into the + * editor's internal clipboard format. Register importers to support additional + * paste sources. */ export interface ClipboardImporter { - readonly name: string; - canImport(text: string): boolean; - import(text: string): ClipboardContent | null; + readonly id: ClipboardSource; + pick(offers: readonly ClipboardOffer[]): ClipboardOffer | null; + import(offer: ClipboardOffer): ClipboardContent | null | Promise; } /** @@ -53,13 +82,6 @@ export interface SystemClipboard { readText(): string; } -/** Current in-memory clipboard state held by the clipboard service. */ -export interface Clipboard { - content: ClipboardContent | null; - bounds: Rect2D | null; - timestamp: number; -} - /** Options controlling where pasted content is placed relative to the original. */ export interface PasteOptions { offset: { x: number; y: number }; diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts index cc960b8e..520abc8c 100644 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { CommandHistory } from "../core/CommandHistory"; import { CutCommand, PasteCommand } from "./ClipboardCommands"; -import { createBridge, expectAt, getAllPoints, getPointCount } from "@/testing"; import type { ClipboardContent } from "../../clipboard/types"; -import type { PointId } from "@shift/types"; +import type { ContourId } from "@shift/types"; +import type { GlyphSource } from "@/lib/model/Glyph"; +import { addContour, addPoint, commandSourceFixture, contourPoints, point } from "../testUtils"; function createTestContent(points: Array<{ x: number; y: number }>): ClipboardContent { return { @@ -21,188 +22,131 @@ function createTestContent(points: Array<{ x: number; y: number }>): ClipboardCo }; } -function addPointToActiveContour( - bridge: ReturnType, - edit: { - id?: PointId; - x: number; - y: number; - pointType: "onCurve" | "offCurve"; - smooth: boolean; - }, -): PointId { - const contourId = bridge.getActiveContourId(); - if (!contourId) throw new Error("No active contour"); - return bridge.addPointToContour(contourId, edit); -} - describe("CutCommand", () => { - let bridge: ReturnType; + let source: GlyphSource; + let contourId: ContourId; let history: CommandHistory; beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); + const fixture = commandSourceFixture(); + source = fixture.source; + contourId = addContour(source); + history = new CommandHistory(fixture.$source); }); - it("should remove points on execute", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); - addPointToActiveContour(bridge, { - id: "" as PointId, - x: 200, - y: 200, - pointType: "onCurve", - smooth: false, - }); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); + it("removes points on execute", () => { + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); + const p2 = addPoint(source, contourId, { x: 200, y: 200 }); + expect(contourPoints(source, contourId).length).toBe(2); history.execute(new CutCommand([p1])); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); - const remaining = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(remaining, 0).x).toBe(200); + expect(contourPoints(source, contourId).length).toBe(1); + expect(point(source, p2).x).toBe(200); }); - it("should restore points on undo", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); + it("restores points on undo", () => { + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); + history.execute(new CutCommand([p1])); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); - const restored = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(restored, 0).x).toBe(100); - expect(expectAt(restored, 0).y).toBe(100); + expect(contourPoints(source, contourId).length).toBe(1); + expect(point(source, p1)).toMatchObject({ x: 100, y: 100 }); }); - it("should remove same points on redo", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); + it("removes same points on redo", () => { + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); + history.execute(new CutCommand([p1])); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(contourPoints(source, contourId).length).toBe(1); history.redo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should handle multiple points", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); - const p2 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 200, - y: 200, - pointType: "onCurve", - smooth: false, - }); - addPointToActiveContour(bridge, { - id: "" as PointId, - x: 300, - y: 300, - pointType: "onCurve", - smooth: false, - }); + it("handles multiple points", () => { + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); + const p2 = addPoint(source, contourId, { x: 200, y: 200 }); + const p3 = addPoint(source, contourId, { x: 300, y: 300 }); history.execute(new CutCommand([p1, p2])); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); - const remaining = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(remaining, 0).x).toBe(300); + expect(contourPoints(source, contourId).length).toBe(1); + expect(point(source, p3).x).toBe(300); }); - it("should have the correct name", () => { - const cmd = new CutCommand([]); - expect(cmd.name).toBe("Cut"); + it("has the correct name", () => { + expect(new CutCommand([]).name).toBe("Cut"); }); }); describe("PasteCommand", () => { - let bridge: ReturnType; + let source: GlyphSource; let history: CommandHistory; beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); + const fixture = commandSourceFixture(); + source = fixture.source; + addContour(source); + history = new CommandHistory(fixture.$source); }); - it("should create points on execute", () => { + it("creates points on execute", () => { + const start = source.allPoints.length; const content = createTestContent([ { x: 100, y: 100 }, { x: 200, y: 200 }, ]); + const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - const cmd = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - history.execute(cmd); + history.execute(command); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); - expect(cmd.createdPointIds.length).toBe(2); + expect(source.allPoints.length).toBe(start + 2); + expect(command.createdPointIds.length).toBe(2); }); - it("should apply offset to pasted points", () => { + it("applies offset to pasted points", () => { const content = createTestContent([{ x: 100, y: 100 }]); + const command = new PasteCommand(content, { offset: { x: 20, y: -20 } }); - const cmd = new PasteCommand(content, { offset: { x: 20, y: -20 } }); - history.execute(cmd); + history.execute(command); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(120); - expect(expectAt(points, 0).y).toBe(80); + expect(point(source, command.createdPointIds[0]!)).toMatchObject({ x: 120, y: 80 }); }); - it("should remove points on undo", () => { + it("removes points on undo", () => { + const start = source.allPoints.length; const content = createTestContent([{ x: 100, y: 100 }]); - const cmd = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - history.execute(cmd); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); + + history.execute(command); + expect(source.allPoints.length).toBe(start + 1); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(source.allPoints.length).toBe(start); }); - it("should restore same state on redo (snapshot-based)", () => { + it("restores same state on redo", () => { + const start = source.allPoints.length; const content = createTestContent([{ x: 100, y: 100 }]); - const cmd = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - history.execute(cmd); - const originalIds = [...cmd.createdPointIds]; + const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); + history.execute(command); + const originalIds = [...command.createdPointIds]; history.undo(); history.redo(); - expect(cmd.createdPointIds).toEqual(originalIds); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(command.createdPointIds).toEqual(originalIds); + expect(source.allPoints.length).toBe(start + 1); }); - it("should handle multiple contours", () => { + it("handles multiple contours", () => { const content: ClipboardContent = { contours: [ { @@ -215,74 +159,61 @@ describe("PasteCommand", () => { }, ], }; + const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - const cmd = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - history.execute(cmd); + history.execute(command); - expect(cmd.createdContourIds.length).toBe(2); + expect(command.createdContourIds.length).toBe(2); }); - it("should have the correct name", () => { + it("has the correct name", () => { const content = createTestContent([]); - const cmd = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - expect(cmd.name).toBe("Paste"); + expect(new PasteCommand(content, { offset: { x: 0, y: 0 } }).name).toBe("Paste"); }); }); describe("Cut + Paste integration", () => { - let bridge: ReturnType; + let source: GlyphSource; + let contourId: ContourId; let history: CommandHistory; beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); + const fixture = commandSourceFixture(); + source = fixture.source; + contourId = addContour(source); + history = new CommandHistory(fixture.$source); }); - it("should support cut then paste workflow", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + it("supports cut then paste workflow", () => { + const start = source.allPoints.length; + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); history.execute(new CutCommand([p1])); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(source.allPoints.length).toBe(start); const content = createTestContent([{ x: 100, y: 100 }]); - const pasteCmd = new PasteCommand(content, { offset: { x: 20, y: 20 } }); - history.execute(pasteCmd); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + const pasteCommand = new PasteCommand(content, { offset: { x: 20, y: 20 } }); + history.execute(pasteCommand); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(120); - expect(expectAt(points, 0).y).toBe(120); + expect(source.allPoints.length).toBe(start + 1); + expect(point(source, pasteCommand.createdPointIds[0]!)).toMatchObject({ x: 120, y: 120 }); }); - it("should undo cut and paste separately", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); + it("undoes cut and paste separately", () => { + const start = source.allPoints.length; + const p1 = addPoint(source, contourId, { x: 100, y: 100 }); history.execute(new CutCommand([p1])); - - const content = createTestContent([{ x: 100, y: 100 }]); - history.execute(new PasteCommand(content, { offset: { x: 0, y: 0 } })); + history.execute( + new PasteCommand(createTestContent([{ x: 100, y: 100 }]), { offset: { x: 0, y: 0 } }), + ); expect(history.undoCount.value).toBe(2); - history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(source.allPoints.length).toBe(start); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(source.allPoints.length).toBe(start + 1); + expect(point(source, p1)).toMatchObject({ x: 100, y: 100 }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts index 0ac1ea9f..5dda67b4 100644 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts @@ -1,5 +1,6 @@ import { BaseCommand, type CommandContext } from "../core/Command"; -import type { PointId, ContourId, GlyphSnapshot } from "@shift/types"; +import type { PointId, ContourId, GlyphState } from "@shift/types"; +import type { GlyphSource } from "@/lib/model/Glyph"; import type { ClipboardContent, PasteOptions } from "../../clipboard/types"; /** @@ -12,7 +13,7 @@ export class CutCommand extends BaseCommand { readonly name = "Cut"; #pointIds: PointId[]; - #beforeSnapshot: GlyphSnapshot | null = null; + #beforeState: GlyphState | null = null; constructor(pointIds: PointId[]) { super(); @@ -20,18 +21,18 @@ export class CutCommand extends BaseCommand { } execute(ctx: CommandContext): void { - this.#beforeSnapshot = ctx.glyph.toSnapshot(); - ctx.glyph.removePoints(this.#pointIds); + this.#beforeState = ctx.source.state; + ctx.source.removePoints(this.#pointIds); } undo(ctx: CommandContext): void { - if (this.#beforeSnapshot) { - ctx.glyph.restoreSnapshot(this.#beforeSnapshot); + if (this.#beforeState) { + ctx.source.restore(this.#beforeState); } } override redo(ctx: CommandContext): void { - ctx.glyph.removePoints(this.#pointIds); + ctx.source.removePoints(this.#pointIds); } } @@ -46,8 +47,8 @@ export class PasteCommand extends BaseCommand { #content: ClipboardContent; #options: PasteOptions; - #beforeSnapshot: GlyphSnapshot | null = null; - #afterSnapshot: GlyphSnapshot | null = null; + #beforeState: GlyphState | null = null; + #afterState: GlyphState | null = null; #createdPointIds: PointId[] = []; #createdContourIds: ContourId[] = []; @@ -58,28 +59,22 @@ export class PasteCommand extends BaseCommand { } execute(ctx: CommandContext): void { - this.#beforeSnapshot = ctx.glyph.toSnapshot(); - - const result = ctx.glyph.pasteContours( - this.#content.contours, - this.#options.offset.x, - this.#options.offset.y, - ); - + this.#beforeState = ctx.source.state; + const result = pasteContours(ctx.source, this.#content, this.#options); this.#createdPointIds = result.createdPointIds; this.#createdContourIds = result.createdContourIds; - this.#afterSnapshot = ctx.glyph.toSnapshot(); + this.#afterState = ctx.source.state; } undo(ctx: CommandContext): void { - if (this.#beforeSnapshot) { - ctx.glyph.restoreSnapshot(this.#beforeSnapshot); + if (this.#beforeState) { + ctx.source.restore(this.#beforeState); } } override redo(ctx: CommandContext): void { - if (this.#afterSnapshot) { - ctx.glyph.restoreSnapshot(this.#afterSnapshot); + if (this.#afterState) { + ctx.source.restore(this.#afterState); } else { this.execute(ctx); } @@ -93,3 +88,33 @@ export class PasteCommand extends BaseCommand { return this.#createdContourIds; } } + +function pasteContours( + source: GlyphSource, + content: ClipboardContent, + options: PasteOptions, +): { createdPointIds: PointId[]; createdContourIds: ContourId[] } { + const createdPointIds: PointId[] = []; + const createdContourIds: ContourId[] = []; + + for (const contour of content.contours) { + const contourId = source.addContour(); + createdContourIds.push(contourId); + + for (const point of contour.points) { + const pointId = source.addPoint(contourId, { + x: point.x + options.offset.x, + y: point.y + options.offset.y, + pointType: point.pointType, + smooth: point.smooth, + }); + createdPointIds.push(pointId); + } + + if (contour.closed) { + source.closeContour(contourId); + } + } + + return { createdPointIds, createdContourIds }; +} diff --git a/apps/desktop/src/renderer/src/lib/commands/core/Command.ts b/apps/desktop/src/renderer/src/lib/commands/core/Command.ts index 672e689e..b1491884 100644 --- a/apps/desktop/src/renderer/src/lib/commands/core/Command.ts +++ b/apps/desktop/src/renderer/src/lib/commands/core/Command.ts @@ -1,18 +1,17 @@ /** * Command Pattern Infrastructure * - * Commands encapsulate glyph mutations as undoable actions. - * Each command receives a Glyph and calls its mutation methods. + * Commands encapsulate source mutations as undoable actions. */ -import type { Glyph } from "@/lib/model/Glyph"; +import type { GlyphSource } from "@/lib/model/Glyph"; /** * Context available to commands during execution. - * Commands receive the reactive Glyph directly — it has all mutation methods. + * Commands receive the active authored source directly. */ export interface CommandContext { - readonly glyph: Glyph; + readonly source: GlyphSource; } export interface Command { diff --git a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts index dcf4b789..3b9eafbe 100644 --- a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.test.ts @@ -1,68 +1,58 @@ -/** - * Tests for CommandHistory integration with commands. - * - * These tests verify: - * - Undo/redo functionality - * - Command execution through history - * - Integration with NativeBridge - */ - -import { describe, it, expect, beforeEach } from "vitest"; -import { CommandHistory } from "./CommandHistory"; -import { AddPointCommand, NudgePointsCommand } from "../primitives"; -import { createBridge, expectAt, getAllPoints, getPointCount } from "@/testing"; +import { beforeEach, describe, expect, it } from "vitest"; import type { PointId } from "@shift/types"; - -function addPointToActiveContour( - bridge: ReturnType, - edit: { - id?: PointId; - x: number; - y: number; - pointType: "onCurve" | "offCurve"; - smooth: boolean; - }, -): PointId { - const contourId = bridge.getActiveContourId(); - if (!contourId) throw new Error("No active contour"); - return bridge.addPointToContour(contourId, edit); -} +import { CommandHistory } from "./CommandHistory"; +import { AddPointCommand } from "../primitives/PointCommands"; +import { NudgePointsCommand } from "../primitives/BezierCommands"; +import { addContour, addPoint, commandSourceFixture, contourPoints, point } from "../testUtils"; +import type { GlyphSource } from "@/lib/model/Glyph"; +import type { ContourId } from "@shift/types"; +import type { Signal } from "@/lib/signals/signal"; describe("CommandHistory", () => { - let bridge: ReturnType; + let source: GlyphSource; + let $source: Signal; + let contourId: ContourId; let history: CommandHistory; beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); + const fixture = commandSourceFixture(); + source = fixture.source; + $source = fixture.$source; + contourId = addContour(source); + history = new CommandHistory($source); }); + function addPointCommand(x: number, y: number): AddPointCommand { + return new AddPointCommand(x, y, "onCurve", false, contourId); + } + + function addSourcePoint(x: number, y: number): PointId { + return addPoint(source, contourId, { x, y }); + } + describe("execute", () => { - it("should execute a command and return the result", () => { - const cmd = new AddPointCommand(100, 200, "onCurve"); - const pointId = history.execute(cmd); + it("executes a command and returns the result", () => { + const pointId = history.execute(addPointCommand(100, 200)); expect(pointId).toBeDefined(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(contourPoints(source, contourId).length).toBe(1); }); - it("should add command to undo stack", () => { + it("adds command to undo stack", () => { expect(history.canUndo.value).toBe(false); - history.execute(new AddPointCommand(100, 200, "onCurve")); + history.execute(addPointCommand(100, 200)); expect(history.canUndo.value).toBe(true); expect(history.undoCount.value).toBe(1); }); - it("should clear redo stack on new command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("clears redo stack on new command", () => { + history.execute(addPointCommand(100, 200)); history.undo(); expect(history.canRedo.value).toBe(true); - history.execute(new AddPointCommand(150, 250, "onCurve")); + history.execute(addPointCommand(150, 250)); expect(history.canRedo.value).toBe(false); expect(history.redoCount.value).toBe(0); @@ -70,18 +60,18 @@ describe("CommandHistory", () => { }); describe("undo", () => { - it("should undo the last command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + it("undoes the last command", () => { + history.execute(addPointCommand(100, 200)); + expect(contourPoints(source, contourId).length).toBe(1); const didUndo = history.undo(); expect(didUndo).toBe(true); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should move command to redo stack", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("moves command to redo stack", () => { + history.execute(addPointCommand(100, 200)); expect(history.canRedo.value).toBe(false); history.undo(); @@ -91,38 +81,37 @@ describe("CommandHistory", () => { expect(history.undoCount.value).toBe(0); }); - it("should return false when stack is empty", () => { - const didUndo = history.undo(); - expect(didUndo).toBe(false); + it("returns false when stack is empty", () => { + expect(history.undo()).toBe(false); }); - it("should undo multiple commands in reverse order", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); - history.execute(new AddPointCommand(150, 250, "onCurve")); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); + it("undoes multiple commands in reverse order", () => { + history.execute(addPointCommand(100, 200)); + history.execute(addPointCommand(150, 250)); + expect(contourPoints(source, contourId).length).toBe(2); - history.undo(); // Remove second point - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + history.undo(); + expect(contourPoints(source, contourId).length).toBe(1); - history.undo(); // Remove first point - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + history.undo(); + expect(contourPoints(source, contourId).length).toBe(0); }); }); describe("redo", () => { - it("should redo the last undone command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("redoes the last undone command", () => { + history.execute(addPointCommand(100, 200)); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); const didRedo = history.redo(); expect(didRedo).toBe(true); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(contourPoints(source, contourId).length).toBe(1); }); - it("should move command back to undo stack", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("moves command back to undo stack", () => { + history.execute(addPointCommand(100, 200)); history.undo(); history.redo(); @@ -131,16 +120,15 @@ describe("CommandHistory", () => { expect(history.canRedo.value).toBe(false); }); - it("should return false when redo stack is empty", () => { - const didRedo = history.redo(); - expect(didRedo).toBe(false); + it("returns false when redo stack is empty", () => { + expect(history.redo()).toBe(false); }); }); describe("clear", () => { - it("should clear both undo and redo stacks", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); - history.execute(new AddPointCommand(150, 250, "onCurve")); + it("clears both undo and redo stacks", () => { + history.execute(addPointCommand(100, 200)); + history.execute(addPointCommand(150, 250)); history.undo(); history.clear(); @@ -153,53 +141,41 @@ describe("CommandHistory", () => { }); describe("labels", () => { - it("should return undo label for the last command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("returns undo label for the last command", () => { + history.execute(addPointCommand(100, 200)); expect(history.getUndoLabel()).toBe("Add Point"); }); - it("should return redo label for the last undone command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); + it("returns redo label for the last undone command", () => { + history.execute(addPointCommand(100, 200)); history.undo(); expect(history.getRedoLabel()).toBe("Add Point"); }); - it("should return null when no commands available", () => { + it("returns null when no commands are available", () => { expect(history.getUndoLabel()).toBe(null); expect(history.getRedoLabel()).toBe(null); }); }); -}); - -describe("batching", () => { - let bridge: ReturnType; - let history: CommandHistory; - - beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); - }); - describe("beginBatch/endBatch", () => { - it("should group multiple commands into single undo step", () => { + describe("batching", () => { + it("groups multiple commands into a single undo step", () => { history.beginBatch("Add Points"); - history.execute(new AddPointCommand(100, 100, "onCurve")); - history.execute(new AddPointCommand(200, 200, "onCurve")); - history.execute(new AddPointCommand(300, 300, "onCurve")); + history.execute(addPointCommand(100, 100)); + history.execute(addPointCommand(200, 200)); + history.execute(addPointCommand(300, 300)); history.endBatch(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(3); + expect(contourPoints(source, contourId).length).toBe(3); expect(history.undoCount.value).toBe(1); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should set isBatching to true during batch", () => { + it("tracks batching state", () => { expect(history.isBatching).toBe(false); history.beginBatch("Test"); expect(history.isBatching).toBe(true); @@ -207,37 +183,37 @@ describe("batching", () => { expect(history.isBatching).toBe(false); }); - it("should throw if beginBatch called while already batching", () => { + it("throws when beginBatch is called while already batching", () => { history.beginBatch("First"); expect(() => history.beginBatch("Second")).toThrow("Cannot nest batches"); }); - it("should throw if endBatch called without beginBatch", () => { + it("throws when endBatch is called without beginBatch", () => { expect(() => history.endBatch()).toThrow("Not in a batch"); }); - it("should not add empty batch to undo stack", () => { + it("does not add empty batch to undo stack", () => { history.beginBatch("Empty"); history.endBatch(); expect(history.undoCount.value).toBe(0); }); - it("should handle single command batch same as non-batched", () => { + it("handles single command batch same as non-batched", () => { history.beginBatch("Single"); - history.execute(new AddPointCommand(100, 100, "onCurve")); + history.execute(addPointCommand(100, 100)); history.endBatch(); expect(history.undoCount.value).toBe(1); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(contourPoints(source, contourId).length).toBe(1); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should use batch name as undo label", () => { + it("uses batch name as undo label", () => { history.beginBatch("Draw Curve"); - history.execute(new AddPointCommand(100, 100, "onCurve")); - history.execute(new AddPointCommand(200, 200, "onCurve")); + history.execute(addPointCommand(100, 100)); + history.execute(addPointCommand(200, 200)); history.endBatch(); expect(history.getUndoLabel()).toBe("Draw Curve"); @@ -245,19 +221,17 @@ describe("batching", () => { }); describe("cancelBatch", () => { - it("should discard batch without adding to undo stack", () => { + it("discards batch without adding to undo stack", () => { history.beginBatch("Cancelled"); - history.execute(new AddPointCommand(100, 100, "onCurve")); - history.execute(new AddPointCommand(200, 200, "onCurve")); + history.execute(addPointCommand(100, 100)); + history.execute(addPointCommand(200, 200)); history.cancelBatch(); - // Points were still added (commands executed) - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); - // But no undo entry + expect(contourPoints(source, contourId).length).toBe(2); expect(history.undoCount.value).toBe(0); }); - it("should reset isBatching state", () => { + it("resets batching state", () => { history.beginBatch("Test"); expect(history.isBatching).toBe(true); history.cancelBatch(); @@ -266,273 +240,144 @@ describe("batching", () => { }); describe("withBatch", () => { - it("should return callback result and group commands into one undo step", () => { + it("returns callback result and groups commands into one undo step", () => { const pointId = history.withBatch("Add Points", () => { - history.execute(new AddPointCommand(100, 100, "onCurve")); - return history.execute(new AddPointCommand(200, 200, "onCurve")); + history.execute(addPointCommand(100, 100)); + return history.execute(addPointCommand(200, 200)); }); expect(pointId).toBeDefined(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); + expect(contourPoints(source, contourId).length).toBe(2); expect(history.undoCount.value).toBe(1); history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should cancel batch and rethrow when callback throws", () => { + it("cancels batch and rethrows when callback throws", () => { expect(() => history.withBatch("Failing Batch", () => { - history.execute(new AddPointCommand(100, 100, "onCurve")); + history.execute(addPointCommand(100, 100)); throw new Error("boom"); }), ).toThrow("boom"); expect(history.isBatching).toBe(false); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + expect(contourPoints(source, contourId).length).toBe(1); expect(history.undoCount.value).toBe(0); }); }); describe("record", () => { - it("should add command to undo stack without executing", () => { - // Add point directly (not through history) - const pointId = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + it("adds command to undo stack without executing", () => { + const pointId = addSourcePoint(100, 100); + expect(contourPoints(source, contourId).length).toBe(1); - // Move point directly - bridge.movePoints([pointId], { x: 50, y: 50 }); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(150); + source.translate([pointId], { x: 50, y: 50 }); + expect(point(source, pointId).x).toBe(150); - // Record the move command (already executed) history.record(new NudgePointsCommand([pointId], 50, 50)); - expect(history.undoCount.value).toBe(1); - // Undo should reverse the already-executed move history.undo(); - const undonePoints = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(undonePoints, 0).x).toBe(100); + expect(point(source, pointId).x).toBe(100); }); - it("should work within a batch", () => { - const pointId = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); + it("works within a batch", () => { + const pointId = addSourcePoint(100, 100); history.beginBatch("Drag"); - bridge.movePoints([pointId], { x: 10, y: 0 }); - bridge.movePoints([pointId], { x: 10, y: 0 }); - bridge.movePoints([pointId], { x: 10, y: 0 }); - // Record single command for total movement + source.translate([pointId], { x: 10, y: 0 }); + source.translate([pointId], { x: 10, y: 0 }); + source.translate([pointId], { x: 10, y: 0 }); history.record(new NudgePointsCommand([pointId], 30, 0)); history.endBatch(); expect(history.undoCount.value).toBe(1); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(130); + expect(point(source, pointId).x).toBe(130); history.undo(); - const undonePoints = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(undonePoints, 0).x).toBe(100); + expect(point(source, pointId).x).toBe(100); }); }); -}); - -describe("onDirty callback", () => { - let bridge: ReturnType; - let history: CommandHistory; - let onDirtyCalled: number; - - beforeEach(() => { - bridge = createBridge(); - onDirtyCalled = 0; - history = new CommandHistory(bridge.$glyph, { - onDirty: () => { - onDirtyCalled++; - }, - }); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); - }); - it("should call onDirty when command is executed", () => { - expect(onDirtyCalled).toBe(0); - history.execute(new AddPointCommand(100, 200, "onCurve")); - expect(onDirtyCalled).toBe(1); - }); + describe("onDirty callback", () => { + it("calls onDirty when commands execute or record", () => { + let onDirtyCalled = 0; + history = new CommandHistory($source, { + onDirty: () => { + onDirtyCalled++; + }, + }); - it("should call onDirty for each executed command", () => { - history.execute(new AddPointCommand(100, 200, "onCurve")); - history.execute(new AddPointCommand(150, 250, "onCurve")); - expect(onDirtyCalled).toBe(2); - }); + history.execute(addPointCommand(100, 200)); + history.execute(addPointCommand(150, 250)); - it("should call onDirty when command is recorded", () => { - const pointId = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, + expect(onDirtyCalled).toBe(2); }); - bridge.movePoints([pointId], { x: 50, y: 50 }); - expect(onDirtyCalled).toBe(0); - history.record(new NudgePointsCommand([pointId], 50, 50)); - expect(onDirtyCalled).toBe(1); - }); - - it("should call onDirty during batch for each command", () => { - history.beginBatch("Add Points"); - history.execute(new AddPointCommand(100, 100, "onCurve")); - expect(onDirtyCalled).toBe(1); - history.execute(new AddPointCommand(200, 200, "onCurve")); - expect(onDirtyCalled).toBe(2); - history.endBatch(); - expect(onDirtyCalled).toBe(2); - }); + it("allows setting onDirty callback after construction", () => { + let lateDirtyCalled = 0; + history.setOnDirty(() => { + lateDirtyCalled++; + }); - it("should allow setting onDirty callback after construction", () => { - const historyNoCallback = new CommandHistory(bridge.$glyph); - let lateDirtyCalled = 0; - historyNoCallback.setOnDirty(() => { - lateDirtyCalled++; + history.execute(addPointCommand(100, 200)); + expect(lateDirtyCalled).toBe(1); }); - historyNoCallback.execute(new AddPointCommand(100, 200, "onCurve")); - expect(lateDirtyCalled).toBe(1); - }); - - it("should not throw if onDirty is not set", () => { - const historyNoCallback = new CommandHistory(bridge.$glyph); - expect(() => { - historyNoCallback.execute(new AddPointCommand(100, 200, "onCurve")); - }).not.toThrow(); - }); -}); - -describe("Command integration with history", () => { - let bridge: ReturnType; - let history: CommandHistory; - - beforeEach(() => { - bridge = createBridge(); - history = new CommandHistory(bridge.$glyph); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); + it("does not throw without onDirty", () => { + expect(() => history.execute(addPointCommand(100, 200))).not.toThrow(); + }); }); - describe("NudgePointsCommand", () => { - it("should nudge points and undo returns them to original position", () => { - const pointId = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 200, - pointType: "onCurve", - smooth: false, - }); + describe("command integration", () => { + it("nudges points and undo returns them to original position", () => { + const pointId = addSourcePoint(100, 200); - history.execute(new NudgePointsCommand([pointId], 10, 0)); // Nudge right - const nudgedPoints = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(nudgedPoints, 0).x).toBe(110); + history.execute(new NudgePointsCommand([pointId], 10, 0)); + expect(point(source, pointId).x).toBe(110); history.undo(); - const restoredPoints = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(restoredPoints, 0).x).toBe(100); + expect(point(source, pointId).x).toBe(100); }); - }); - describe("Complex undo/redo sequences", () => { - it("should handle move undo/redo on existing points", () => { - // Add point directly (not through history) - const pointId = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 200, - pointType: "onCurve", - smooth: false, - }); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + it("handles move undo and redo on existing points", () => { + const pointId = addSourcePoint(100, 200); - // Move point through history history.execute(new NudgePointsCommand([pointId], 50, 50)); - let points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(150); - expect(expectAt(points, 0).y).toBe(250); + expect(point(source, pointId)).toMatchObject({ x: 150, y: 250 }); - // Undo move history.undo(); - points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(100); - expect(expectAt(points, 0).y).toBe(200); + expect(point(source, pointId)).toMatchObject({ x: 100, y: 200 }); - // Redo move history.redo(); - points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(150); - expect(expectAt(points, 0).y).toBe(250); + expect(point(source, pointId)).toMatchObject({ x: 150, y: 250 }); }); - it("should handle add undo/redo", () => { - // Add point through history - history.execute(new AddPointCommand(100, 200, "onCurve")); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + it("handles add undo and redo", () => { + history.execute(addPointCommand(100, 200)); + expect(contourPoints(source, contourId).length).toBe(1); - // Undo add history.undo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); + expect(contourPoints(source, contourId).length).toBe(0); - // Redo add (creates new point, potentially with different ID) history.redo(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(100); - expect(expectAt(points, 0).y).toBe(200); - }); - - it("should handle multiple points with single command", () => { - const p1 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); - const p2 = addPointToActiveContour(bridge, { - id: "" as PointId, - x: 200, - y: 200, - pointType: "onCurve", - smooth: false, - }); + expect(contourPoints(source, contourId).length).toBe(1); + expect(contourPoints(source, contourId)[0]).toMatchObject({ x: 100, y: 200 }); + }); + + it("handles multiple points with a single command", () => { + const p1 = addSourcePoint(100, 100); + const p2 = addSourcePoint(200, 200); - // Move both points together history.execute(new NudgePointsCommand([p1, p2], 50, 50)); - let points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(150); - expect(expectAt(points, 0).y).toBe(150); - expect(expectAt(points, 1).x).toBe(250); - expect(expectAt(points, 1).y).toBe(250); + expect(point(source, p1)).toMatchObject({ x: 150, y: 150 }); + expect(point(source, p2)).toMatchObject({ x: 250, y: 250 }); - // Undo moves both back history.undo(); - points = getAllPoints(bridge.getEditingSnapshot()); - expect(expectAt(points, 0).x).toBe(100); - expect(expectAt(points, 0).y).toBe(100); - expect(expectAt(points, 1).x).toBe(200); - expect(expectAt(points, 1).y).toBe(200); + expect(point(source, p1)).toMatchObject({ x: 100, y: 100 }); + expect(point(source, p2)).toMatchObject({ x: 200, y: 200 }); }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.ts b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.ts index 1d462dc4..5a8a221c 100644 --- a/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.ts +++ b/apps/desktop/src/renderer/src/lib/commands/core/CommandHistory.ts @@ -17,10 +17,10 @@ import { type WritableSignal, type ComputedSignal, type Signal, -} from "@/lib/reactive/signal"; +} from "@/lib/signals/signal"; import type { Command, CommandContext } from "./Command"; import { CompositeCommand } from "./Command"; -import type { Glyph } from "@/lib/model/Glyph"; +import type { GlyphSource } from "@/lib/model/Glyph"; export interface CommandHistoryOptions { /** Maximum number of commands to keep in history */ @@ -38,7 +38,7 @@ export class CommandHistory { #undoStack: Command[] = []; #redoStack: Command[] = []; #maxHistory: number; - #$glyph: Signal; + #$source: Signal; #batch: BatchState | null = null; #onDirty: (() => void) | undefined; @@ -47,8 +47,8 @@ export class CommandHistory { readonly canUndo: ComputedSignal; readonly canRedo: ComputedSignal; - constructor($glyph: Signal, options: CommandHistoryOptions = {}) { - this.#$glyph = $glyph; + constructor($source: Signal, options: CommandHistoryOptions = {}) { + this.#$source = $source; this.#maxHistory = options.maxHistory ?? 100; this.#onDirty = options.onDirty; @@ -67,7 +67,10 @@ export class CommandHistory { } #createContext(): CommandContext { - return { glyph: this.#$glyph.peek()! }; + const source = this.#$source.peek(); + if (!source) throw new Error("Cannot execute command without an active glyph source"); + + return { source }; } #updateCounts(): void { diff --git a/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md index a04d5665..c30aba78 100644 --- a/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md @@ -4,7 +4,7 @@ Command pattern implementation providing undo/redo for all glyph editing operati ## Architecture Invariants -- **Architecture Invariant:** Commands mutate the glyph exclusively through `CommandContext.glyph` (the reactive `Glyph` model). They never touch the native bridge directly. +- **Architecture Invariant:** Commands mutate the active editable source exclusively through `CommandContext.source`. They never touch the native bridge directly. - **Architecture Invariant:** Every command must be self-contained for undo. `execute` must capture enough state (original positions, snapshot, etc.) so that `undo` can fully reverse the operation without external help. - **Architecture Invariant:** `CompositeCommand` undoes children in reverse order. Commands grouped via `beginBatch`/`endBatch` are auto-wrapped in a `CompositeCommand` at `endBatch` time. - **Architecture Invariant:** `record` adds a command to the undo stack without calling `execute`. This is the correct path for incremental operations (e.g. drag) where mutations have already been applied live. **CRITICAL:** Using `execute` instead of `record` for already-applied mutations will double-apply them. @@ -20,10 +20,9 @@ commands/ CommandHistory.ts # Undo/redo stacks, batching, reactive signals primitives/ PointCommands.ts # AddPointCommand - BezierCommands.ts # CloseContourCommand, NudgePointsCommand, SetActiveContourCommand, - # ReverseContourCommand, SplitSegmentCommand, UpgradeLineToCubicCommand - SetNodePositionsCommand.ts # Efficient bulk position updates (before/after lists) - SnapshotCommand.ts # Whole-glyph snapshot undo (catch-all) + BezierCommands.ts # CloseContourCommand, NudgePointsCommand, ReverseContourCommand, + # SplitSegmentCommand, UpgradeLineToCubicCommand + SetSourcePositionsCommand.ts # Efficient bulk position updates (before/after lists) SidebearingCommands.ts # SetXAdvanceCommand, SetLeftSidebearingCommand, SetRightSidebearingCommand transform/ TransformCommands.ts # RotatePointsCommand, ScalePointsCommand, ReflectPointsCommand, MoveSelectionToCommand @@ -35,13 +34,12 @@ commands/ ## Key Types - **`Command`** -- Interface: `name`, `execute(ctx)`, `undo(ctx)`, `redo(ctx)`. All commands implement this. -- **`CommandContext`** -- `{ readonly glyph: Glyph }`. Injected into every command method. Provides access to all glyph mutation methods. +- **`CommandContext`** -- `{ readonly source: GlyphSource }`. Injected into every command method. Provides access to the active editable source. - **`BaseCommand`** -- Abstract class implementing `Command`. Default `redo` calls `execute`; subclasses override when redo needs different logic (e.g. snapshot-based replay). - **`CompositeCommand`** -- Groups multiple commands into one undo step. Executes children in order, undoes in reverse. - **`CommandHistory`** -- Manages undo/redo stacks. Exposes `execute`, `record`, `undo`, `redo`, `clear`, batching (`beginBatch`/`endBatch`/`withBatch`), and reactive signals (`canUndo`, `canRedo`). - **`CommandHistoryOptions`** -- `{ maxHistory?: number; onDirty?: () => void }`. `maxHistory` defaults to 100. -- **`SnapshotCommand`** -- Stores before/after `GlyphSnapshot`. Catch-all for complex operations where fine-grained undo is impractical (e.g. boolean operations). -- **`SetNodePositionsCommand`** -- Stores before/after `NodePositionUpdateList`. Efficient path for move-only operations avoiding full snapshot overhead. Has static factories `fromBaseGlyphAndUpdates` and `fromGlyphDiff`. +- **`SetSourcePositionsCommand`** -- Stores before/after `SourcePositions`. Efficient path for point/anchor position-only operations. - **`BaseTransformCommand`** -- Abstract template in `TransformCommands.ts`. Captures original positions on first execute; subclasses implement `transformPoints`. Used by `RotatePointsCommand`, `ScalePointsCommand`, `ReflectPointsCommand`, `MoveSelectionToCommand`. ## How it works @@ -53,7 +51,7 @@ commands/ ### execute vs record - **`execute(cmd)`** -- Calls `cmd.execute(ctx)`, then pushes to undo stack. Use for discrete one-shot operations (add point, nudge, transform). -- **`record(cmd)`** -- Pushes to undo stack without calling execute. Use when mutations have already been applied incrementally (e.g. dragging points). The editor's `startNodeDrag` pattern applies live position updates during the drag, then calls `record(SetNodePositionsCommand.fromBaseGlyphAndUpdates(...))` at drag end. +- **`record(cmd)`** -- Pushes to undo stack without calling execute. Use when mutations have already been applied incrementally (e.g. dragging points). `SourceEditDraft.commit()` applies the final source positions, then records a `SetSourcePositionsCommand`. ### Batching @@ -65,9 +63,9 @@ Commands use one of three strategies depending on cost: 1. **Delta-based** -- Store the delta, apply inverse on undo. Used by `NudgePointsCommand`. 2. **Position-capture** -- Store original positions, restore on undo. Used by `BaseTransformCommand` subclasses, `AlignPointsCommand`, `DistributePointsCommand`, `SplitSegmentCommand`. -3. **Snapshot-based** -- Store full `GlyphSnapshot` before/after. Used by `SnapshotCommand`, `CutCommand`, `PasteCommand`. Most expensive but handles topology changes (adding/removing contours). +3. **Source-state-based** -- Store full `GlyphState` before/after. Used by `CutCommand` and `PasteCommand` for topology changes (adding/removing contours). -`SetNodePositionsCommand` is a hybrid: it stores before/after position lists (cheaper than full snapshots) and replays them on undo/redo. +`SetSourcePositionsCommand` is a hybrid: it stores before/after position lists (cheaper than full snapshots) and replays them on undo/redo. ### Reactive UI @@ -83,7 +81,7 @@ Commands use one of three strategies depending on cost: 1. Create a class extending `BaseCommand` (or implementing `Command` directly). 2. Set `readonly name` to a human-readable label (shown in undo/redo menus). -3. Implement `execute(ctx)` -- perform the mutation via `ctx.glyph.*` methods and capture any state needed for undo. +3. Implement `execute(ctx)` -- perform the mutation via `ctx.source.*` methods and capture any state needed for undo. 4. Implement `undo(ctx)` -- fully reverse the mutation. 5. Override `redo(ctx)` only if re-executing from scratch would fail (e.g. id drift after point removal). Default `redo` calls `execute`. 6. Export from the appropriate subdirectory's `index.ts` and from the top-level `commands/index.ts`. @@ -98,10 +96,9 @@ Commands use one of three strategies depending on cost: ## Gotchas -- `SnapshotCommand` does not extend `BaseCommand` -- it implements `Command` directly (its `@knipclassignore` annotation prevents dead-code detection from flagging it, since it is only instantiated dynamically). - `SetLeftSidebearingCommand` moves all geometry (`translateLayer`) in addition to changing `xAdvance`. Undo must reverse both, and the order matters (restore advance first, then translate back). - `SplitSegmentCommand.redo` resets internal state (`#insertedPointIds`, `#originalPositions`) before re-executing because point ids are engine-assigned and differ across executions. -- `PasteCommand` captures a full after-snapshot on first execute and uses snapshot restore for redo, avoiding id-drift issues from creating new contours/points twice. +- `PasteCommand` captures full source state after first execute and restores it for redo, avoiding id-drift issues from creating new contours/points twice. - `cancelBatch` does not undo already-executed commands -- it only discards the batch bookkeeping. Callers must handle rollback separately if needed. ## Verification @@ -116,10 +113,9 @@ npx vitest run --project renderer src/lib/editor/ ## Related -- `Glyph` -- Reactive glyph model; all commands mutate through `ctx.glyph` +- `GlyphSource` -- Active editable glyph source; commands mutate through `ctx.source` - `Editor` -- Orchestrates command execution; owns the `CommandHistory` instance - `Signal`, `ComputedSignal` -- Reactive primitives powering `canUndo`/`canRedo` -- `GlyphSnapshot` -- Serialized glyph state used by `SnapshotCommand` and snapshot-based undo -- `NodePositionUpdateList` -- Typed position update lists used by `SetNodePositionsCommand` +- `SourcePositions` -- Typed point/anchor position lists used by `SetSourcePositionsCommand` - `Transform` -- Pure math functions for rotate/scale/reflect, consumed by transform commands - `Alignment` -- Pure math for align/distribute, consumed by `AlignPointsCommand`/`DistributePointsCommand` diff --git a/apps/desktop/src/renderer/src/lib/commands/index.ts b/apps/desktop/src/renderer/src/lib/commands/index.ts index 75ba0bf2..9fd8973e 100644 --- a/apps/desktop/src/renderer/src/lib/commands/index.ts +++ b/apps/desktop/src/renderer/src/lib/commands/index.ts @@ -11,13 +11,16 @@ export { // Primitive commands (point, bezier operations) export { AddPointCommand, + DrawRectangleCommand, + ToggleSmoothCommand, CloseContourCommand, - SetActiveContourCommand, ReverseContourCommand, NudgePointsCommand, SplitSegmentCommand, UpgradeLineToCubicCommand, - SetNodePositionsCommand, + BooleanOperationCommand, + type BooleanOperation, + SetSourcePositionsCommand, SetXAdvanceCommand, SetLeftSidebearingCommand, SetRightSidebearingCommand, diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts index 8cf1e83f..07180997 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts @@ -1,309 +1,263 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { CloseContourCommand, NudgePointsCommand, SplitSegmentCommand } from "./BezierCommands"; -import { createBridge, getAllPoints, getPointCount } from "@/testing"; -import type { NativeBridge } from "@/bridge"; -import type { CommandContext } from "../core"; -import { Segment } from "@/lib/model/Segment"; -import type { QuadSegment, CubicSegment } from "@/types/segments"; +import { describe, expect, it } from "vitest"; import type { PointId } from "@shift/types"; - -let bridge: NativeBridge; - -function ctx(): CommandContext { - return { glyph: bridge.$glyph.peek()! }; -} - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); -}); +import { CloseContourCommand, NudgePointsCommand, SplitSegmentCommand } from "./BezierCommands"; +import { Segment, type CubicSegment, type QuadSegment } from "@shift/glyph-state"; +import { addContour, addPoint, commandSourceFixture, contourPoints, point } from "../testUtils"; describe("CloseContourCommand", () => { - it("should close the active contour", () => { - bridge.addContour(); - bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const cmd = new CloseContourCommand(); + it("closes the contour", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + addPoint(source, contourId, { x: 0, y: 0 }); + const command = new CloseContourCommand(contourId); - cmd.execute(ctx()); + command.execute(ctx); - const glyph = bridge.getEditingSnapshot()!; - expect(glyph.contours[0]!.closed).toBe(true); + expect(source.contour(contourId)?.closed).toBe(true); }); - it("should not close if already closed", () => { - bridge.addContour(); - bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - bridge.closeContour(); + it("does not reopen an already closed contour on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + addPoint(source, contourId, { x: 0, y: 0 }); + source.closeContour(contourId); + const command = new CloseContourCommand(contourId); - const cmd = new CloseContourCommand(); - cmd.execute(ctx()); + command.execute(ctx); + command.undo(ctx); - const glyph = bridge.getEditingSnapshot()!; - expect(glyph.contours[0]!.closed).toBe(true); + expect(source.contour(contourId)?.closed).toBe(true); }); - it("should have the correct name", () => { - const cmd = new CloseContourCommand(); - expect(cmd.name).toBe("Close Contour"); + it("has the correct name", () => { + const command = new CloseContourCommand(0 as never); + expect(command.name).toBe("Close Contour"); }); }); describe("NudgePointsCommand", () => { - it("should move points by the nudge delta", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 30, y: 40, pointType: "onCurve", smooth: false }); - const cmd = new NudgePointsCommand([p1, p2], 1, 0); + it("moves points by the nudge delta", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const p1 = addPoint(source, contourId, { x: 10, y: 20 }); + const p2 = addPoint(source, contourId, { x: 30, y: 40 }); + const command = new NudgePointsCommand([p1, p2], 1, 0); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(11); - expect(points[1]!.x).toBe(31); + expect(point(source, p1).x).toBe(11); + expect(point(source, p2).x).toBe(31); }); - it("should move points back on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new NudgePointsCommand([p1], 5, -10); + it("moves points back on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const p1 = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new NudgePointsCommand([p1], 5, -10); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(10); - expect(points[0]!.y).toBe(20); + expect(point(source, p1)).toMatchObject({ x: 10, y: 20 }); }); - it("should not change state with empty array", () => { - bridge.addContour(); - bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new NudgePointsCommand([], 5, 5); + it("does not change state with empty array", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const p1 = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new NudgePointsCommand([], 5, 5); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(10); - expect(points[0]!.y).toBe(20); + expect(point(source, p1)).toMatchObject({ x: 10, y: 20 }); }); - it("should have the correct name", () => { - const cmd = new NudgePointsCommand([], 0, 0); - expect(cmd.name).toBe("Nudge Points"); + it("has the correct name", () => { + const command = new NudgePointsCommand([], 0, 0); + expect(command.name).toBe("Nudge Points"); }); }); describe("SplitSegmentCommand", () => { - function makeLineSegment(p1Id: PointId, p2Id: PointId): Segment { - const points = getAllPoints(bridge.getEditingSnapshot()); - const p1 = points.find((p) => p.id === p1Id)!; - const p2 = points.find((p) => p.id === p2Id)!; - + function makeLineSegment( + source: ReturnType["source"], + sourcePoint1: PointId, + sourcePoint2: PointId, + ): Segment { return new Segment({ type: "line", points: { - anchor1: { id: p1.id, x: p1.x, y: p1.y, pointType: "onCurve", smooth: false }, - anchor2: { id: p2.id, x: p2.x, y: p2.y, pointType: "onCurve", smooth: false }, + anchor1: point(source, sourcePoint1), + anchor2: point(source, sourcePoint2), }, }); } + function fixture() { + const result = commandSourceFixture(); + const contourId = addContour(result.source); + return { ...result, contourId }; + } + describe("line segment", () => { - it("should insert a single on-curve point at t=0.5", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - const segment = makeLineSegment(p1, p2); - const cmd = new SplitSegmentCommand(segment, 0.5); - - const result = cmd.execute(ctx()); - - expect(getPointCount(bridge.getEditingSnapshot())).toBe(3); - expect(result).toBeTruthy(); - expect(cmd.splitPointId).toBe(result); - - const points = getAllPoints(bridge.getEditingSnapshot()); - const splitPoint = points.find((p) => p.id === result)!; - expect(splitPoint.x).toBe(50); - expect(splitPoint.y).toBe(0); - expect(splitPoint.pointType).toBe("onCurve"); + it("inserts a single on-curve point at t=0.5", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); + const command = new SplitSegmentCommand(makeLineSegment(source, p1, p2), 0.5); + + const result = command.execute(ctx); + + expect(contourPoints(source, contourId).length).toBe(3); + expect(command.splitPointId).toBe(result); + expect(point(source, result)).toMatchObject({ x: 50, y: 0, pointType: "onCurve" }); }); - it("should insert point at correct position for t=0.25", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 100, pointType: "onCurve", smooth: false }); - const segment = makeLineSegment(p1, p2); - const cmd = new SplitSegmentCommand(segment, 0.25); + it("inserts point at correct position for t=0.25", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const p2 = addPoint(source, contourId, { x: 100, y: 100 }); + const command = new SplitSegmentCommand(makeLineSegment(source, p1, p2), 0.25); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - const splitPoint = points.find((p) => p.id === cmd.splitPointId)!; - expect(splitPoint.x).toBe(25); - expect(splitPoint.y).toBe(25); + expect(point(source, command.splitPointId!)).toMatchObject({ x: 25, y: 25 }); }); - it("should remove inserted point on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - const segment = makeLineSegment(p1, p2); - const cmd = new SplitSegmentCommand(segment, 0.5); + it("removes inserted point on undo", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); + const command = new SplitSegmentCommand(makeLineSegment(source, p1, p2), 0.5); - cmd.execute(ctx()); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(3); + command.execute(ctx); + expect(contourPoints(source, contourId).length).toBe(3); - cmd.undo(ctx()); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(2); + command.undo(ctx); + expect(contourPoints(source, contourId).length).toBe(2); }); }); describe("quadratic segment", () => { - it("should insert mid point and new control for quad split", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const c1 = bridge.addPoint({ x: 50, y: 100, pointType: "offCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - + it("inserts mid point and new control for quad split", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const c1 = addPoint(source, contourId, { x: 50, y: 100, pointType: "offCurve" }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); const segment: QuadSegment = { type: "quad", points: { - anchor1: { id: p1, x: 0, y: 0, pointType: "onCurve", smooth: false }, - control: { id: c1, x: 50, y: 100, pointType: "offCurve", smooth: false }, - anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false }, + anchor1: point(source, p1), + control: point(source, c1), + anchor2: point(source, p2), }, }; - const cmd = new SplitSegmentCommand(new Segment(segment), 0.5); - - cmd.execute(ctx()); + const command = new SplitSegmentCommand(new Segment(segment), 0.5); - // Original 3 + 2 inserted = 5 - expect(getPointCount(bridge.getEditingSnapshot())).toBe(5); + command.execute(ctx); - // The split point should be on-curve and smooth - const allPoints = getAllPoints(bridge.getEditingSnapshot()); - const splitPoint = allPoints.find((p) => p.id === cmd.splitPointId)!; - expect(splitPoint.pointType).toBe("onCurve"); - expect(splitPoint.smooth).toBe(true); + expect(contourPoints(source, contourId).length).toBe(5); + expect(point(source, command.splitPointId!)).toMatchObject({ + pointType: "onCurve", + smooth: true, + }); }); - it("should restore original state on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const c1 = bridge.addPoint({ x: 50, y: 100, pointType: "offCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - + it("restores original state on undo", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const c1 = addPoint(source, contourId, { x: 50, y: 100, pointType: "offCurve" }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); const segment: QuadSegment = { type: "quad", points: { - anchor1: { id: p1, x: 0, y: 0, pointType: "onCurve", smooth: false }, - control: { id: c1, x: 50, y: 100, pointType: "offCurve", smooth: false }, - anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false }, + anchor1: point(source, p1), + control: point(source, c1), + anchor2: point(source, p2), }, }; - const cmd = new SplitSegmentCommand(new Segment(segment), 0.5); - - cmd.execute(ctx()); - cmd.undo(ctx()); + const command = new SplitSegmentCommand(new Segment(segment), 0.5); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(3); + command.execute(ctx); + command.undo(ctx); - // Original control should be restored to its original position - const allPoints = getAllPoints(bridge.getEditingSnapshot()); - const control = allPoints.find((p) => p.id === c1)!; - expect(control.x).toBe(50); - expect(control.y).toBe(100); + expect(contourPoints(source, contourId).length).toBe(3); + expect(point(source, c1)).toMatchObject({ x: 50, y: 100 }); }); }); describe("cubic segment", () => { - it("should insert 3 points for cubic split", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const c1 = bridge.addPoint({ x: 25, y: 100, pointType: "offCurve", smooth: false }); - const c2 = bridge.addPoint({ x: 75, y: 100, pointType: "offCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - + it("inserts three points for cubic split", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const c1 = addPoint(source, contourId, { x: 25, y: 100, pointType: "offCurve" }); + const c2 = addPoint(source, contourId, { x: 75, y: 100, pointType: "offCurve" }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); const segment: CubicSegment = { type: "cubic", points: { - anchor1: { id: p1, x: 0, y: 0, pointType: "onCurve", smooth: false }, - control1: { id: c1, x: 25, y: 100, pointType: "offCurve", smooth: false }, - control2: { id: c2, x: 75, y: 100, pointType: "offCurve", smooth: false }, - anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false }, + anchor1: point(source, p1), + control1: point(source, c1), + control2: point(source, c2), + anchor2: point(source, p2), }, }; - const cmd = new SplitSegmentCommand(new Segment(segment), 0.5); - - const result = cmd.execute(ctx()); + const command = new SplitSegmentCommand(new Segment(segment), 0.5); - // Original 4 + 3 inserted = 7 - expect(getPointCount(bridge.getEditingSnapshot())).toBe(7); - expect(result).toBe(cmd.splitPointId); + const result = command.execute(ctx); - // The split point should be on-curve and smooth - const allPoints = getAllPoints(bridge.getEditingSnapshot()); - const splitPoint = allPoints.find((p) => p.id === cmd.splitPointId)!; - expect(splitPoint.pointType).toBe("onCurve"); - expect(splitPoint.smooth).toBe(true); + expect(contourPoints(source, contourId).length).toBe(7); + expect(result).toBe(command.splitPointId); + expect(point(source, command.splitPointId!)).toMatchObject({ + pointType: "onCurve", + smooth: true, + }); }); - it("should restore both control positions on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const c1 = bridge.addPoint({ x: 25, y: 100, pointType: "offCurve", smooth: false }); - const c2 = bridge.addPoint({ x: 75, y: 100, pointType: "offCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - + it("restores both control positions on undo", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const c1 = addPoint(source, contourId, { x: 25, y: 100, pointType: "offCurve" }); + const c2 = addPoint(source, contourId, { x: 75, y: 100, pointType: "offCurve" }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); const segment: CubicSegment = { type: "cubic", points: { - anchor1: { id: p1, x: 0, y: 0, pointType: "onCurve", smooth: false }, - control1: { id: c1, x: 25, y: 100, pointType: "offCurve", smooth: false }, - control2: { id: c2, x: 75, y: 100, pointType: "offCurve", smooth: false }, - anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false }, + anchor1: point(source, p1), + control1: point(source, c1), + control2: point(source, c2), + anchor2: point(source, p2), }, }; - const cmd = new SplitSegmentCommand(new Segment(segment), 0.5); + const command = new SplitSegmentCommand(new Segment(segment), 0.5); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(4); - - const allPoints = getAllPoints(bridge.getEditingSnapshot()); - const control1 = allPoints.find((p) => p.id === c1)!; - const control2 = allPoints.find((p) => p.id === c2)!; - expect(control1.x).toBe(25); - expect(control1.y).toBe(100); - expect(control2.x).toBe(75); - expect(control2.y).toBe(100); + expect(contourPoints(source, contourId).length).toBe(4); + expect(point(source, c1)).toMatchObject({ x: 25, y: 100 }); + expect(point(source, c2)).toMatchObject({ x: 75, y: 100 }); }); }); - describe("redo", () => { - it("should clear state and re-execute", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - const segment = makeLineSegment(p1, p2); - const cmd = new SplitSegmentCommand(segment, 0.5); + it("clears state and re-executes on redo", () => { + const { source, ctx, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); + const command = new SplitSegmentCommand(makeLineSegment(source, p1, p2), 0.5); - cmd.execute(ctx()); - cmd.undo(ctx()); - cmd.redo(ctx()); + command.execute(ctx); + command.undo(ctx); + command.redo(ctx); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(3); - }); + expect(contourPoints(source, contourId).length).toBe(3); }); - it("should have the correct name", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 0, y: 0, pointType: "onCurve", smooth: false }); - const p2 = bridge.addPoint({ x: 100, y: 0, pointType: "onCurve", smooth: false }); - const segment = makeLineSegment(p1, p2); - const cmd = new SplitSegmentCommand(segment, 0.5); - expect(cmd.name).toBe("Split Segment"); + it("has the correct name", () => { + const { source, contourId } = fixture(); + const p1 = addPoint(source, contourId, { x: 0, y: 0 }); + const p2 = addPoint(source, contourId, { x: 100, y: 0 }); + const command = new SplitSegmentCommand(makeLineSegment(source, p1, p2), 0.5); + expect(command.name).toBe("Split Segment"); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts index 2f40940e..6ad6cb91 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts @@ -1,8 +1,7 @@ -import type { PointId, ContourId, Point2D } from "@shift/types"; +import type { PointId, ContourId } from "@shift/types"; import { BaseCommand, type CommandContext } from "../core/Command"; -import { type CubicCurve, type QuadraticCurve } from "@shift/geo"; -import type { LineSegment } from "@/types/segments"; -import type { Segment } from "@/lib/model/Segment"; +import { Point2D, type CubicCurve, type QuadraticCurve } from "@shift/geo"; +import type { LineSegment, Segment } from "@shift/glyph-state"; /** * Closes the active contour, connecting the last point back to the first. @@ -12,29 +11,28 @@ import type { Segment } from "@/lib/model/Segment"; export class CloseContourCommand extends BaseCommand { readonly name = "Close Contour"; - #contourId: ContourId | null = null; + #contourId: ContourId; #wasClosed: boolean = false; - constructor() { + constructor(contourId: ContourId) { super(); + this.#contourId = contourId; } execute(ctx: CommandContext): void { - this.#contourId = ctx.glyph.activeContourId; + const contour = ctx.source.contour(this.#contourId); + if (!contour) throw new Error("Expected contour"); - if (this.#contourId) { - const contour = ctx.glyph.contour(this.#contourId); - this.#wasClosed = contour?.closed ?? false; - } + this.#wasClosed = contour.closed; if (!this.#wasClosed) { - ctx.glyph.closeContour(); + ctx.source.closeContour(this.#contourId); } } undo(ctx: CommandContext): void { - if (this.#contourId && !this.#wasClosed) { - ctx.glyph.openContour(this.#contourId); + if (!this.#wasClosed) { + ctx.source.openContour(this.#contourId); } } } @@ -69,35 +67,7 @@ export class NudgePointsCommand extends BaseCommand { #apply(ctx: CommandContext, dx: number, dy: number): void { if (this.#pointIds.length === 0) return; - ctx.glyph.translate(this.#pointIds, { x: dx, y: dy }); - } -} - -/** - * Switches the active contour in the font engine. New points are appended - * to the active contour, so this controls where subsequent drawing lands. - * Undo restores the previously active contour. - */ -export class SetActiveContourCommand extends BaseCommand { - readonly name = "Set Active Contour"; - - #contourId: ContourId; - #previousActiveId: ContourId | null = null; - - constructor(contourId: ContourId) { - super(); - this.#contourId = contourId; - } - - execute(ctx: CommandContext): void { - this.#previousActiveId = ctx.glyph.activeContourId; - ctx.glyph.setActiveContour(this.#contourId); - } - - undo(ctx: CommandContext): void { - if (this.#previousActiveId) { - ctx.glyph.setActiveContour(this.#previousActiveId); - } + ctx.source.translate(this.#pointIds, { x: dx, y: dy }); } } @@ -117,11 +87,11 @@ export class ReverseContourCommand extends BaseCommand { } execute(ctx: CommandContext): void { - ctx.glyph.reverseContour(this.#contourId); + ctx.source.reverseContour(this.#contourId); } undo(ctx: CommandContext): void { - ctx.glyph.reverseContour(this.#contourId); + ctx.source.reverseContour(this.#contourId); } } @@ -164,7 +134,7 @@ export class SplitSegmentCommand extends BaseCommand { const anchor2Id = this.#segment.anchor2.id; - this.#splitPointId = ctx.glyph.insertPointBefore(anchor2Id, { + this.#splitPointId = ctx.source.insertPointBefore(anchor2Id, { x: splitPoint.x, y: splitPoint.y, pointType: "onCurve", @@ -191,7 +161,7 @@ export class SplitSegmentCommand extends BaseCommand { y: data.points.control.y, }); - this.#splitPointId = ctx.glyph.insertPointBefore(anchor2Id, { + this.#splitPointId = ctx.source.insertPointBefore(anchor2Id, { x: mid.x, y: mid.y, pointType: "onCurve", @@ -199,7 +169,7 @@ export class SplitSegmentCommand extends BaseCommand { }); this.#insertedPointIds.push(this.#splitPointId); - const cBId = ctx.glyph.insertPointBefore(anchor2Id, { + const cBId = ctx.source.insertPointBefore(anchor2Id, { x: cB.x, y: cB.y, pointType: "offCurve", @@ -207,7 +177,7 @@ export class SplitSegmentCommand extends BaseCommand { }); this.#insertedPointIds.push(cBId); - ctx.glyph.movePointTo(controlId, cA); + ctx.source.movePointTo(controlId, cA); return this.#splitPointId; } @@ -234,7 +204,7 @@ export class SplitSegmentCommand extends BaseCommand { y: data.points.control2.y, }); - const c1AId = ctx.glyph.insertPointBefore(control2Id, { + const c1AId = ctx.source.insertPointBefore(control2Id, { x: c1A.x, y: c1A.y, pointType: "offCurve", @@ -242,7 +212,7 @@ export class SplitSegmentCommand extends BaseCommand { }); this.#insertedPointIds.push(c1AId); - this.#splitPointId = ctx.glyph.insertPointBefore(control2Id, { + this.#splitPointId = ctx.source.insertPointBefore(control2Id, { x: mid.x, y: mid.y, pointType: "onCurve", @@ -250,7 +220,7 @@ export class SplitSegmentCommand extends BaseCommand { }); this.#insertedPointIds.push(this.#splitPointId); - const c0BId = ctx.glyph.insertPointBefore(control2Id, { + const c0BId = ctx.source.insertPointBefore(control2Id, { x: c0B.x, y: c0B.y, pointType: "offCurve", @@ -258,19 +228,19 @@ export class SplitSegmentCommand extends BaseCommand { }); this.#insertedPointIds.push(c0BId); - ctx.glyph.movePointTo(control1Id, c0A); - ctx.glyph.movePointTo(control2Id, c1B); + ctx.source.movePointTo(control1Id, c0A); + ctx.source.movePointTo(control2Id, c1B); return this.#splitPointId; } undo(ctx: CommandContext): void { if (this.#insertedPointIds.length > 0) { - ctx.glyph.removePoints(this.#insertedPointIds); + ctx.source.removePoints(this.#insertedPointIds); } for (const [pointId, pos] of this.#originalPositions) { - ctx.glyph.movePointTo(pointId, pos); + ctx.source.movePointTo(pointId, pos); } } @@ -319,13 +289,13 @@ export class UpgradeLineToCubicCommand extends BaseCommand { } execute(ctx: CommandContext): void { - this.#control2Id = ctx.glyph.insertPointBefore(this.#anchor2Id, { + this.#control2Id = ctx.source.insertPointBefore(this.#anchor2Id, { x: this.#control2Pos.x, y: this.#control2Pos.y, pointType: "offCurve", smooth: false, }); - this.#control1Id = ctx.glyph.insertPointBefore(this.#control2Id, { + this.#control1Id = ctx.source.insertPointBefore(this.#control2Id, { x: this.#control1Pos.x, y: this.#control1Pos.y, pointType: "offCurve", @@ -336,7 +306,7 @@ export class UpgradeLineToCubicCommand extends BaseCommand { undo(ctx: CommandContext): void { const toRemove = [this.#control1Id, this.#control2Id].filter(Boolean) as PointId[]; if (toRemove.length > 0) { - ctx.glyph.removePoints(toRemove); + ctx.source.removePoints(toRemove); } } diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts new file mode 100644 index 00000000..113d7f6b --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts @@ -0,0 +1,42 @@ +import type { ContourId, GlyphState } from "@shift/types"; +import { BaseCommand, type CommandContext } from "../core/Command"; + +export type BooleanOperation = "union" | "subtract" | "intersect" | "difference"; + +export class BooleanOperationCommand extends BaseCommand { + readonly name: string; + + readonly #contourIdA: ContourId; + readonly #contourIdB: ContourId; + readonly #operation: BooleanOperation; + #beforeState: GlyphState | null = null; + #afterState: GlyphState | null = null; + + constructor(contourIdA: ContourId, contourIdB: ContourId, operation: BooleanOperation) { + super(); + this.name = `Boolean ${operation}`; + this.#contourIdA = contourIdA; + this.#contourIdB = contourIdB; + this.#operation = operation; + } + + execute(ctx: CommandContext): void { + this.#beforeState = ctx.source.state; + ctx.source.applyBooleanOp(this.#contourIdA, this.#contourIdB, this.#operation); + this.#afterState = ctx.source.state; + } + + undo(ctx: CommandContext): void { + if (this.#beforeState) { + ctx.source.restore(this.#beforeState); + } + } + + override redo(ctx: CommandContext): void { + if (this.#afterState) { + ctx.source.restore(this.#afterState); + } else { + this.execute(ctx); + } + } +} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts index 51e3bf74..ffac89ac 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts @@ -1,69 +1,69 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { AddPointCommand } from "./PointCommands"; -import { createBridge, getAllPoints, getPointCount } from "@/testing"; -import type { NativeBridge } from "@/bridge"; -import type { CommandContext } from "../core"; +import { describe, expect, it } from "vitest"; +import type { ContourId } from "@shift/types"; +import { AddPointCommand, ToggleSmoothCommand } from "./PointCommands"; +import { addContour, addPoint, commandSourceFixture, contourPoints, point } from "../testUtils"; -let bridge: NativeBridge; - -function ctx(): CommandContext { - return { glyph: bridge.$glyph.peek()! }; -} +describe("AddPointCommand", () => { + it("adds a point at specified coordinates", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const command = new AddPointCommand(100, 200, "onCurve", false, contourId); -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); -}); + const pointId = command.execute(ctx); -describe("AddPointCommand", () => { - it("should add a point at specified coordinates", () => { - bridge.addContour(); - const cmd = new AddPointCommand(100, 200, "onCurve"); + expect(contourPoints(source, contourId).length).toBe(1); + expect(point(source, pointId)).toMatchObject({ x: 100, y: 200, pointType: "onCurve" }); + }); - const pointId = cmd.execute(ctx()); + it("adds a smooth point", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const command = new AddPointCommand(50, 75, "onCurve", true, contourId); - expect(pointId).toBeTruthy(); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); + const pointId = command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(100); - expect(points[0]!.y).toBe(200); - expect(points[0]!.pointType).toBe("onCurve"); + expect(point(source, pointId).smooth).toBe(true); }); - it("should add a smooth point", () => { - bridge.addContour(); - const cmd = new AddPointCommand(50, 75, "onCurve", true); + it("adds an off-curve point", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const command = new AddPointCommand(30, 40, "offCurve", false, contourId); - cmd.execute(ctx()); + const pointId = command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.smooth).toBe(true); + expect(point(source, pointId).pointType).toBe("offCurve"); }); - it("should add an offCurve point", () => { - bridge.addContour(); - const cmd = new AddPointCommand(30, 40, "offCurve"); + it("removes the point on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const command = new AddPointCommand(100, 200, "onCurve", false, contourId); - cmd.execute(ctx()); + command.execute(ctx); + expect(contourPoints(source, contourId).length).toBe(1); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.pointType).toBe("offCurve"); + command.undo(ctx); + expect(contourPoints(source, contourId).length).toBe(0); }); - it("should remove the point on undo", () => { - bridge.addContour(); - const cmd = new AddPointCommand(100, 200, "onCurve"); + it("has the correct name", () => { + const command = new AddPointCommand(0, 0, "onCurve", false, 0 as ContourId); + expect(command.name).toBe("Add Point"); + }); +}); - cmd.execute(ctx()); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(1); +describe("ToggleSmoothCommand", () => { + it("toggles smooth and toggles back on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200, smooth: false }); + const command = new ToggleSmoothCommand(pointId); - cmd.undo(ctx()); - expect(getPointCount(bridge.getEditingSnapshot())).toBe(0); - }); + command.execute(ctx); + expect(point(source, pointId).smooth).toBe(true); - it("should have the correct name", () => { - const cmd = new AddPointCommand(0, 0, "onCurve"); - expect(cmd.name).toBe("Add Point"); + command.undo(ctx); + expect(point(source, pointId).smooth).toBe(false); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts index d9e73c33..e45fe26d 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts @@ -9,7 +9,7 @@ import { BaseCommand, type CommandContext } from "../core/Command"; export class AddPointCommand extends BaseCommand { readonly name = "Add Point"; - #contourId: ContourId | null; + #contourId: ContourId; #x: number; #y: number; #pointType: PointType; @@ -22,7 +22,7 @@ export class AddPointCommand extends BaseCommand { y: number, pointType: PointType, smooth: boolean = false, - contourId: ContourId | null = null, + contourId: ContourId, ) { super(); this.#contourId = contourId; @@ -33,11 +33,7 @@ export class AddPointCommand extends BaseCommand { } execute(ctx: CommandContext): PointId { - const contourId = this.#contourId ?? ctx.glyph.activeContourId; - if (!contourId) { - throw new Error("No active contour"); - } - this.#resultId = ctx.glyph.addPointToContour(contourId, { + this.#resultId = ctx.source.addPoint(this.#contourId, { x: this.#x, y: this.#y, pointType: this.#pointType, @@ -48,7 +44,7 @@ export class AddPointCommand extends BaseCommand { undo(ctx: CommandContext): void { if (this.#resultId) { - ctx.glyph.removePoints([this.#resultId]); + ctx.source.removePoints([this.#resultId]); } } @@ -56,3 +52,22 @@ export class AddPointCommand extends BaseCommand { return this.execute(ctx); } } + +export class ToggleSmoothCommand extends BaseCommand { + readonly name = "Toggle Smooth"; + + readonly #pointId: PointId; + + constructor(pointId: PointId) { + super(); + this.#pointId = pointId; + } + + execute(ctx: CommandContext): void { + ctx.source.toggleSmooth(this.#pointId); + } + + undo(ctx: CommandContext): void { + ctx.source.toggleSmooth(this.#pointId); + } +} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts deleted file mode 100644 index 19d37599..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { SetNodePositionsCommand } from "./SetNodePositionsCommand"; -import { createBridge } from "@/testing"; -import type { GlyphSnapshot } from "@shift/types"; -import { asAnchorId, asContourId, asPointId } from "@shift/types"; -import type { NativeBridge } from "@/bridge"; -import type { CommandContext } from "../core"; - -function makeGlyph(input: { - contours?: GlyphSnapshot["contours"]; - anchors?: GlyphSnapshot["anchors"]; -}): GlyphSnapshot { - return { - unicode: 65, - name: "A", - xAdvance: 500, - contours: input.contours ?? [], - anchors: input.anchors ?? [], - compositeContours: [], - activeContourId: input.contours?.[0]?.id ?? null, - }; -} - -let bridge: NativeBridge; - -function ctx(): CommandContext { - return { glyph: bridge.$glyph.peek()! }; -} - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); - bridge.addContour(); -}); - -describe("SetNodePositionsCommand", () => { - it("derives batched point and anchor updates from a move-only glyph diff", () => { - const before = makeGlyph({ - contours: [ - { - id: asContourId("contour-1"), - closed: false, - points: [ - { id: asPointId("point-1"), x: 10, y: 20, pointType: "onCurve", smooth: false }, - { id: asPointId("point-2"), x: 30, y: 40, pointType: "offCurve", smooth: false }, - ], - }, - ], - anchors: [{ id: asAnchorId("anchor-1"), name: "top", x: 1, y: 2 }], - }); - const after = makeGlyph({ - contours: [ - { - id: asContourId("contour-1"), - closed: false, - points: [ - { id: asPointId("point-1"), x: 15, y: 25, pointType: "onCurve", smooth: false }, - { id: asPointId("point-2"), x: 30, y: 40, pointType: "offCurve", smooth: false }, - ], - }, - ], - anchors: [{ id: asAnchorId("anchor-1"), name: "top", x: 4, y: 5 }], - }); - - const command = SetNodePositionsCommand.fromGlyphDiff("Move Selection", before, after); - - expect(command).not.toBeNull(); - - // Load the "after" state into the engine so undo can move it back to "before" - bridge.restoreSnapshot(after); - command!.execute(ctx()); - - // Verify the after positions were applied - const glyph = bridge.getEditingSnapshot()!; - const p1 = glyph.contours[0]!.points.find((p) => p.id === asPointId("point-1"))!; - expect(p1.x).toBe(15); - expect(p1.y).toBe(25); - - command!.undo(ctx()); - - // Verify the before positions were restored - const undoGlyph = bridge.getEditingSnapshot()!; - const p1Undo = undoGlyph.contours[0]!.points.find((p) => p.id === asPointId("point-1"))!; - expect(p1Undo.x).toBe(10); - expect(p1Undo.y).toBe(20); - }); - - it("falls back when the glyph diff changes topology", () => { - const before = makeGlyph({ - contours: [ - { - id: asContourId("contour-1"), - closed: false, - points: [{ id: asPointId("point-1"), x: 10, y: 20, pointType: "onCurve", smooth: false }], - }, - ], - }); - const after = makeGlyph({ - contours: [ - { - id: asContourId("contour-1"), - closed: false, - points: [ - { id: asPointId("point-1"), x: 10, y: 20, pointType: "onCurve", smooth: false }, - { id: asPointId("point-2"), x: 30, y: 40, pointType: "onCurve", smooth: false }, - ], - }, - ], - }); - - expect(SetNodePositionsCommand.fromGlyphDiff("Move Selection", before, after)).toBeNull(); - }); - - it("derives before updates directly from a base glyph and known updates", () => { - const base = makeGlyph({ - contours: [ - { - id: asContourId("contour-1"), - closed: false, - points: [{ id: asPointId("point-1"), x: 10, y: 20, pointType: "onCurve", smooth: false }], - }, - ], - anchors: [{ id: asAnchorId("anchor-1"), name: "top", x: 1, y: 2 }], - }); - - const command = SetNodePositionsCommand.fromBaseGlyphAndUpdates("Move Selection", base, [ - { node: { kind: "point", id: asPointId("point-1") }, x: 15, y: 25 }, - { node: { kind: "anchor", id: asAnchorId("anchor-1") }, x: 4, y: 5 }, - ]); - - expect(command).not.toBeNull(); - - // Load the base state and apply the "after" updates, then undo - bridge.restoreSnapshot(base); - command!.execute(ctx()); - - const glyph = bridge.getEditingSnapshot()!; - const p1 = glyph.contours[0]!.points.find((p) => p.id === asPointId("point-1"))!; - expect(p1.x).toBe(15); - expect(p1.y).toBe(25); - - command!.undo(ctx()); - - const undoGlyph = bridge.getEditingSnapshot()!; - const p1Undo = undoGlyph.contours[0]!.points.find((p) => p.id === asPointId("point-1"))!; - expect(p1Undo.x).toBe(10); - expect(p1Undo.y).toBe(20); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.ts deleted file mode 100644 index e7fd62e3..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SetNodePositionsCommand.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { GlyphSnapshot } from "@shift/types"; -import { Glyphs } from "@shift/font"; -import { BaseCommand, type CommandContext } from "../core/Command"; -import type { NodePositionUpdate, NodePositionUpdateList } from "@/types/positionUpdate"; - -/** - * Replays absolute point/anchor position updates in a single batched editing call. - * Useful for undo/redo of large move-only operations where restoring the whole - * glyph snapshot would be unnecessarily expensive. - */ -export class SetNodePositionsCommand extends BaseCommand { - readonly name: string; - - readonly #before: NodePositionUpdateList; - readonly #after: NodePositionUpdateList; - - constructor(label: string, before: NodePositionUpdateList, after: NodePositionUpdateList) { - super(); - this.name = label; - this.#before = before; - this.#after = after; - } - - execute(ctx: CommandContext): void { - ctx.glyph.setNodePositions(this.#after); - } - - undo(ctx: CommandContext): void { - ctx.glyph.setNodePositions(this.#before); - } - - override redo(ctx: CommandContext): void { - ctx.glyph.setNodePositions(this.#after); - } - - static fromBaseGlyphAndUpdates( - label: string, - baseGlyph: GlyphSnapshot, - after: NodePositionUpdateList, - ): SetNodePositionsCommand | null { - if (after.length === 0) return null; - - const pointPositions = new Map( - Glyphs.getAllPoints(baseGlyph).map((point) => [point.id, point] as const), - ); - const anchorPositions = new Map( - baseGlyph.anchors.map((anchor) => [anchor.id, anchor] as const), - ); - - const before: NodePositionUpdate[] = []; - - for (const update of after) { - switch (update.node.kind) { - case "point": { - const point = pointPositions.get(update.node.id); - if (!point) return null; - before.push({ - node: { kind: "point", id: point.id }, - x: point.x, - y: point.y, - }); - break; - } - case "anchor": { - const anchor = anchorPositions.get(update.node.id); - if (!anchor) return null; - before.push({ - node: { kind: "anchor", id: anchor.id }, - x: anchor.x, - y: anchor.y, - }); - break; - } - case "guideline": - return null; - } - } - - return new SetNodePositionsCommand(label, before, [...after]); - } - - static fromGlyphDiff( - label: string, - before: GlyphSnapshot, - after: GlyphSnapshot, - ): SetNodePositionsCommand | null { - if ( - before.unicode !== after.unicode || - before.name !== after.name || - before.xAdvance !== after.xAdvance || - before.activeContourId !== after.activeContourId || - before.contours.length !== after.contours.length || - before.anchors.length !== after.anchors.length - ) { - return null; - } - - const beforeUpdates: NodePositionUpdate[] = []; - const afterUpdates: NodePositionUpdate[] = []; - - for (let contourIndex = 0; contourIndex < before.contours.length; contourIndex += 1) { - const beforeContour = before.contours[contourIndex]; - const afterContour = after.contours[contourIndex]; - - if ( - !beforeContour || - !afterContour || - beforeContour.id !== afterContour.id || - beforeContour.closed !== afterContour.closed || - beforeContour.points.length !== afterContour.points.length - ) { - return null; - } - - for (let pointIndex = 0; pointIndex < beforeContour.points.length; pointIndex += 1) { - const beforePoint = beforeContour.points[pointIndex]; - const afterPoint = afterContour.points[pointIndex]; - - if ( - !beforePoint || - !afterPoint || - beforePoint.id !== afterPoint.id || - beforePoint.pointType !== afterPoint.pointType || - beforePoint.smooth !== afterPoint.smooth - ) { - return null; - } - - if (beforePoint.x === afterPoint.x && beforePoint.y === afterPoint.y) { - continue; - } - - beforeUpdates.push({ - node: { kind: "point", id: beforePoint.id }, - x: beforePoint.x, - y: beforePoint.y, - }); - afterUpdates.push({ - node: { kind: "point", id: afterPoint.id }, - x: afterPoint.x, - y: afterPoint.y, - }); - } - } - - for (let anchorIndex = 0; anchorIndex < before.anchors.length; anchorIndex += 1) { - const beforeAnchor = before.anchors[anchorIndex]; - const afterAnchor = after.anchors[anchorIndex]; - - if ( - !beforeAnchor || - !afterAnchor || - beforeAnchor.id !== afterAnchor.id || - beforeAnchor.name !== afterAnchor.name - ) { - return null; - } - - if (beforeAnchor.x === afterAnchor.x && beforeAnchor.y === afterAnchor.y) { - continue; - } - - beforeUpdates.push({ - node: { kind: "anchor", id: beforeAnchor.id }, - x: beforeAnchor.x, - y: beforeAnchor.y, - }); - afterUpdates.push({ - node: { kind: "anchor", id: afterAnchor.id }, - x: afterAnchor.x, - y: afterAnchor.y, - }); - } - - return new SetNodePositionsCommand(label, beforeUpdates, afterUpdates); - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.test.ts new file mode 100644 index 00000000..ef36ea59 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { createBridge } from "@shift/bridge"; +import type { PointId } from "@shift/types"; +import { SetSourcePositionsCommand } from "./SetSourcePositionsCommand"; +import { Font } from "@/lib/model/Font"; +import type { GlyphSource } from "@/lib/model/Glyph"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import type { CommandContext } from "../core"; + +function editableSource(): GlyphSource { + const bridge = createBridge(); + const font = new Font(bridge); + font.load(MUTATORSANS_DESIGNSPACE); + + const handle = { name: "A", unicode: 65 }; + const source = font.sourceAt(font.defaultLocation()); + if (!source) throw new Error("Expected editable source"); + bridge.startEditSession(handle, source.id); + + const glyphSource = font.glyphSource(handle, source); + if (!glyphSource) throw new Error("Expected editable glyph source"); + + return glyphSource; +} + +function ctx(source: GlyphSource): CommandContext { + return { source }; +} + +function pointPosition(source: GlyphSource, pointId: PointId): { x: number; y: number } { + const point = source.point(pointId); + if (!point) throw new Error("Expected point"); + + return { x: point.x, y: point.y }; +} + +describe("SetSourcePositionsCommand", () => { + it("replays after positions and restores before positions", () => { + const source = editableSource(); + const point = source.allPoints[0]; + if (!point) throw new Error("Expected point"); + + const before = [{ kind: "point" as const, id: point.id, ...pointPosition(source, point.id) }]; + const after = [ + { kind: "point" as const, id: point.id, x: before[0].x + 15, y: before[0].y + 5 }, + ]; + const command = new SetSourcePositionsCommand("Move Selection", before, after); + + command.execute(ctx(source)); + expect(pointPosition(source, point.id)).toEqual({ x: after[0].x, y: after[0].y }); + + command.undo(ctx(source)); + expect(pointPosition(source, point.id)).toEqual({ x: before[0].x, y: before[0].y }); + + command.redo(ctx(source)); + expect(pointPosition(source, point.id)).toEqual({ x: after[0].x, y: after[0].y }); + }); + + it("derives before positions from the source", () => { + const source = editableSource(); + const point = source.allPoints[0]; + if (!point) throw new Error("Expected point"); + + const start = pointPosition(source, point.id); + const after = [{ kind: "point" as const, id: point.id, x: start.x + 15, y: start.y + 5 }]; + const command = SetSourcePositionsCommand.fromSource("Move Selection", source, after); + if (!command) throw new Error("Expected command"); + + command.execute(ctx(source)); + expect(pointPosition(source, point.id)).toEqual({ x: after[0].x, y: after[0].y }); + + command.undo(ctx(source)); + expect(pointPosition(source, point.id)).toEqual(start); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.ts new file mode 100644 index 00000000..a3dd4833 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SetSourcePositionsCommand.ts @@ -0,0 +1,41 @@ +import { BaseCommand, type CommandContext } from "../core/Command"; +import type { GlyphSource, SourcePositions } from "@/lib/model/Glyph"; + +/** + * Replays absolute point/anchor position updates through the source bulk path. + * This is the undoable form of `GlyphSource.setPositions`. + */ +export class SetSourcePositionsCommand extends BaseCommand { + readonly name: string; + + readonly #before: SourcePositions; + readonly #after: SourcePositions; + + constructor(label: string, before: SourcePositions, after: SourcePositions) { + super(); + this.name = label; + this.#before = before; + this.#after = after; + } + + execute(ctx: CommandContext): void { + ctx.source.setPositions(this.#after); + } + + undo(ctx: CommandContext): void { + ctx.source.setPositions(this.#before); + } + + override redo(ctx: CommandContext): void { + ctx.source.setPositions(this.#after); + } + + static fromSource(label: string, source: GlyphSource, after: SourcePositions) { + if (after.length === 0) return null; + + const before = source.positionsFor(after); + if (before.length !== after.length) return null; + + return new SetSourcePositionsCommand(label, before, after); + } +} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts new file mode 100644 index 00000000..70e66ed5 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { DrawRectangleCommand } from "./ShapeCommands"; +import { commandSourceFixture } from "../testUtils"; + +describe("DrawRectangleCommand", () => { + it("adds a closed four-point contour", () => { + const { source, ctx } = commandSourceFixture(); + const command = new DrawRectangleCommand(rect(10, 20, 100, 50)); + + const contourId = command.execute(ctx); + const contour = source.contour(contourId); + + expect(contour?.closed).toBe(true); + expect(contour?.points.map(({ x, y }) => ({ x, y }))).toEqual([ + { x: 10, y: 20 }, + { x: 110, y: 20 }, + { x: 110, y: 70 }, + { x: 10, y: 70 }, + ]); + }); + + it("removes created points on undo", () => { + const { source, ctx } = commandSourceFixture(); + const command = new DrawRectangleCommand(rect(0, 0, 10, 10)); + + const contourId = command.execute(ctx); + command.undo(ctx); + + expect(source.contour(contourId)?.points).toEqual([]); + }); +}); + +function rect(x: number, y: number, width: number, height: number) { + return { + x, + y, + width, + height, + left: Math.min(x, x + width), + top: Math.min(y, y + height), + right: Math.max(x, x + width), + bottom: Math.max(y, y + height), + }; +} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts new file mode 100644 index 00000000..ce88a1ff --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts @@ -0,0 +1,62 @@ +import type { Rect2D } from "@shift/geo"; +import type { ContourId, PointId } from "@shift/types"; +import type { PointEdit } from "@/lib/model/Glyph"; +import { BaseCommand, type CommandContext } from "../core/Command"; + +export class DrawRectangleCommand extends BaseCommand { + readonly name = "Draw Rectangle"; + + readonly #rect: Rect2D; + #contourId: ContourId | null = null; + #pointIds: PointId[] = []; + + constructor(rect: Rect2D) { + super(); + this.#rect = rect; + } + + execute(ctx: CommandContext): ContourId { + this.#pointIds = []; + this.#contourId = ctx.source.addContour(); + + this.#pointIds.push( + ctx.source.addPoint(this.#contourId, pointEdit(this.#rect.x, this.#rect.y)), + ); + this.#pointIds.push( + ctx.source.addPoint( + this.#contourId, + pointEdit(this.#rect.x + this.#rect.width, this.#rect.y), + ), + ); + this.#pointIds.push( + ctx.source.addPoint( + this.#contourId, + pointEdit(this.#rect.x + this.#rect.width, this.#rect.y + this.#rect.height), + ), + ); + this.#pointIds.push( + ctx.source.addPoint( + this.#contourId, + pointEdit(this.#rect.x, this.#rect.y + this.#rect.height), + ), + ); + ctx.source.closeContour(this.#contourId); + + return this.#contourId; + } + + undo(ctx: CommandContext): void { + if (this.#pointIds.length > 0) { + ctx.source.removePoints(this.#pointIds); + } + } +} + +function pointEdit(x: number, y: number): PointEdit { + return { + x, + y, + pointType: "onCurve", + smooth: false, + }; +} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts index a4e29d77..8b032436 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts @@ -1,99 +1,90 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { describe, expect, it } from "vitest"; import { SetLeftSidebearingCommand, SetRightSidebearingCommand, SetXAdvanceCommand, } from "./SidebearingCommands"; -import { createBridge, getAllPoints } from "@/testing"; -import type { NativeBridge } from "@/bridge"; -import type { CommandContext } from "../core"; - -let bridge: NativeBridge; - -function ctx(): CommandContext { - return { glyph: bridge.$glyph.peek()! }; -} - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); -}); +import { addContour, addPoint, commandSourceFixture, point } from "../testUtils"; describe("SetXAdvanceCommand", () => { it("sets xAdvance on execute", () => { - const cmd = new SetXAdvanceCommand(500, 530); + const { source, ctx } = commandSourceFixture(); + const command = new SetXAdvanceCommand(500, 530); - cmd.execute(ctx()); + command.execute(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(530); + expect(source.geometry.xAdvance).toBe(530); }); it("restores xAdvance on undo", () => { - const cmd = new SetXAdvanceCommand(500, 530); + const { source, ctx } = commandSourceFixture(); + const command = new SetXAdvanceCommand(500, 530); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(500); + expect(source.geometry.xAdvance).toBe(500); }); }); describe("SetRightSidebearingCommand", () => { it("sets xAdvance on execute", () => { - const cmd = new SetRightSidebearingCommand(500, 530); + const { source, ctx } = commandSourceFixture(); + const command = new SetRightSidebearingCommand(500, 530); - cmd.execute(ctx()); + command.execute(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(530); + expect(source.geometry.xAdvance).toBe(530); }); it("restores xAdvance on undo", () => { - const cmd = new SetRightSidebearingCommand(500, 530); + const { source, ctx } = commandSourceFixture(); + const command = new SetRightSidebearingCommand(500, 530); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(500); + expect(source.geometry.xAdvance).toBe(500); }); }); describe("SetLeftSidebearingCommand", () => { it("translates geometry then sets advance on execute", () => { - bridge.addContour(); - bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new SetLeftSidebearingCommand(500, 520, 20); + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new SetLeftSidebearingCommand(500, 520, 20); - cmd.execute(ctx()); + command.execute(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(520); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(120); + expect(source.geometry.xAdvance).toBe(520); + expect(point(source, pointId).x).toBe(120); }); it("reverts advance and translation on undo", () => { - bridge.addContour(); - bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new SetLeftSidebearingCommand(500, 520, 20); + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new SetLeftSidebearingCommand(500, 520, 20); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(500); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(100); + expect(source.geometry.xAdvance).toBe(500); + expect(point(source, pointId).x).toBe(100); }); it("reapplies translation and advance on redo", () => { - bridge.addContour(); - bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new SetLeftSidebearingCommand(500, 520, 20); + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new SetLeftSidebearingCommand(500, 520, 20); - cmd.execute(ctx()); - cmd.undo(ctx()); - cmd.redo(ctx()); + command.execute(ctx); + command.undo(ctx); + command.redo(ctx); - expect(bridge.getEditingSnapshot()!.xAdvance).toBe(520); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(120); + expect(source.geometry.xAdvance).toBe(520); + expect(point(source, pointId).x).toBe(120); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts index 9f7b2593..4fcab825 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts @@ -13,15 +13,15 @@ export class SetXAdvanceCommand extends BaseCommand { } execute(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.setXAdvance(this.#afterXAdvance); } undo(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#beforeXAdvance); + ctx.source.setXAdvance(this.#beforeXAdvance); } override redo(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.setXAdvance(this.#afterXAdvance); } } @@ -38,15 +38,15 @@ export class SetRightSidebearingCommand extends BaseCommand { } execute(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.setXAdvance(this.#afterXAdvance); } undo(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#beforeXAdvance); + ctx.source.setXAdvance(this.#beforeXAdvance); } override redo(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.setXAdvance(this.#afterXAdvance); } } @@ -65,17 +65,17 @@ export class SetLeftSidebearingCommand extends BaseCommand { } execute(ctx: CommandContext): void { - ctx.glyph.translateLayer(this.#deltaX, 0); - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.translateLayer(this.#deltaX, 0); + ctx.source.setXAdvance(this.#afterXAdvance); } undo(ctx: CommandContext): void { - ctx.glyph.setXAdvance(this.#beforeXAdvance); - ctx.glyph.translateLayer(-this.#deltaX, 0); + ctx.source.setXAdvance(this.#beforeXAdvance); + ctx.source.translateLayer(-this.#deltaX, 0); } override redo(ctx: CommandContext): void { - ctx.glyph.translateLayer(this.#deltaX, 0); - ctx.glyph.setXAdvance(this.#afterXAdvance); + ctx.source.translateLayer(this.#deltaX, 0); + ctx.source.setXAdvance(this.#afterXAdvance); } } diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SnapshotCommand.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SnapshotCommand.ts deleted file mode 100644 index fd40b009..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SnapshotCommand.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { GlyphSnapshot } from "@shift/types"; -import type { CommandContext } from "../core"; - -/** - * A whole-glyph undo/redo command that stores before and after snapshots. - * Use this as a catch-all when fine-grained point commands are impractical - * (e.g. complex multi-step operations). Execute and redo restore the "after" - * snapshot; undo restores the "before" snapshot. The label is caller-provided - * so the undo history displays meaningful operation names. - * - * Implements the Command interface (name, execute, undo, redo). - * @knipclassignore - */ -export class SnapshotCommand { - readonly name: string; - readonly #before: GlyphSnapshot; - readonly #after: GlyphSnapshot; - - constructor(label: string, before: GlyphSnapshot, after: GlyphSnapshot) { - this.name = label; - this.#before = before; - this.#after = after; - } - - execute(ctx: CommandContext): void { - ctx.glyph.restoreSnapshot(this.#after); - } - - undo(ctx: CommandContext): void { - ctx.glyph.restoreSnapshot(this.#before); - } - - redo(ctx: CommandContext): void { - ctx.glyph.restoreSnapshot(this.#after); - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts index ae6e6d7d..9b3e2e5e 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts @@ -1,14 +1,14 @@ -export { AddPointCommand } from "./PointCommands"; +export { AddPointCommand, ToggleSmoothCommand } from "./PointCommands"; +export { DrawRectangleCommand } from "./ShapeCommands"; export { CloseContourCommand, - SetActiveContourCommand, ReverseContourCommand, NudgePointsCommand, SplitSegmentCommand, UpgradeLineToCubicCommand, } from "./BezierCommands"; -export { SnapshotCommand } from "./SnapshotCommand"; -export { SetNodePositionsCommand } from "./SetNodePositionsCommand"; +export { BooleanOperationCommand, type BooleanOperation } from "./BooleanOperationCommand"; +export { SetSourcePositionsCommand } from "./SetSourcePositionsCommand"; export { SetXAdvanceCommand, SetLeftSidebearingCommand, diff --git a/apps/desktop/src/renderer/src/lib/commands/testUtils.ts b/apps/desktop/src/renderer/src/lib/commands/testUtils.ts new file mode 100644 index 00000000..9670cd7e --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/commands/testUtils.ts @@ -0,0 +1,67 @@ +import { createBridge } from "@shift/bridge"; +import type { ContourId, PointId, PointType } from "@shift/types"; +import { signal, type Signal } from "@/lib/signals/signal"; +import { Font } from "@/lib/model/Font"; +import type { GlyphSource } from "@/lib/model/Glyph"; +import type { Point } from "@shift/glyph-state"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import type { CommandContext } from "./core"; + +export interface CommandSourceFixture { + readonly source: GlyphSource; + readonly $source: Signal; + readonly ctx: CommandContext; +} + +export function commandSourceFixture(): CommandSourceFixture { + const bridge = createBridge(); + const font = new Font(bridge); + font.load(MUTATORSANS_DESIGNSPACE); + + const handle = { name: "A", unicode: 65 }; + const source = font.sourceAt(font.defaultLocation()); + if (!source) throw new Error("Expected editable source"); + bridge.startEditSession(handle, source.id); + + const glyphSource = font.glyphSource(handle, source); + if (!glyphSource) throw new Error("Expected editable glyph source"); + return { + source: glyphSource, + $source: signal(glyphSource), + ctx: { source: glyphSource }, + }; +} + +export function addContour(source: GlyphSource): ContourId { + return source.addContour(); +} + +export function addPoint( + source: GlyphSource, + contourId: ContourId, + edit: { + readonly x: number; + readonly y: number; + readonly pointType?: PointType; + readonly smooth?: boolean; + }, +): PointId { + return source.addPoint(contourId, { + x: edit.x, + y: edit.y, + pointType: edit.pointType ?? "onCurve", + smooth: edit.smooth ?? false, + }); +} + +export function point(source: GlyphSource, pointId: PointId): Point { + const result = source.point(pointId); + if (!result) throw new Error("Expected point"); + return result; +} + +export function contourPoints(source: GlyphSource, contourId: ContourId): readonly Point[] { + const contour = source.contour(contourId); + if (!contour) throw new Error("Expected contour"); + return contour.points; +} diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts b/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts index beb838e5..56e2f4a2 100644 --- a/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts @@ -1,4 +1,5 @@ -import type { Point2D, PointId } from "@shift/types"; +import type { PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import { BaseCommand, type CommandContext } from "../core/Command"; import type { AlignmentType, DistributeType } from "@/types/transform"; @@ -24,7 +25,7 @@ export class AlignPointsCommand extends BaseCommand { execute(ctx: CommandContext): void { if (this.#pointIds.length === 0) return; - const points = ctx.glyph.points(this.#pointIds); + const points = ctx.source.points(this.#pointIds); if (points.length === 0) return; if (this.#originalPositions.size === 0) { @@ -33,17 +34,17 @@ export class AlignPointsCommand extends BaseCommand { } } - ctx.glyph.align(this.#pointIds, this.#alignment); + ctx.source.align(this.#pointIds, this.#alignment); } undo(ctx: CommandContext): void { for (const [id, pos] of this.#originalPositions) { - ctx.glyph.movePointTo(id, pos); + ctx.source.movePointTo(id, pos); } } override redo(ctx: CommandContext): void { - ctx.glyph.align(this.#pointIds, this.#alignment); + ctx.source.align(this.#pointIds, this.#alignment); } } @@ -69,7 +70,7 @@ export class DistributePointsCommand extends BaseCommand { execute(ctx: CommandContext): void { if (this.#pointIds.length < 3) return; - const points = ctx.glyph.points(this.#pointIds); + const points = ctx.source.points(this.#pointIds); if (points.length < 3) return; if (this.#originalPositions.size === 0) { @@ -78,16 +79,16 @@ export class DistributePointsCommand extends BaseCommand { } } - ctx.glyph.distribute(this.#pointIds, this.#type); + ctx.source.distribute(this.#pointIds, this.#type); } undo(ctx: CommandContext): void { for (const [id, pos] of this.#originalPositions) { - ctx.glyph.movePointTo(id, pos); + ctx.source.movePointTo(id, pos); } } override redo(ctx: CommandContext): void { - ctx.glyph.distribute(this.#pointIds, this.#type); + ctx.source.distribute(this.#pointIds, this.#type); } } diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts index fd243031..599ee973 100644 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts @@ -1,186 +1,163 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { RotatePointsCommand, ScalePointsCommand, ReflectPointsCommand } from "./TransformCommands"; -import { createBridge, getAllPoints } from "@/testing"; -import type { NativeBridge } from "@/bridge"; -import type { CommandContext } from "../core"; - -let bridge: NativeBridge; - -function ctx(): CommandContext { - return { glyph: bridge.$glyph.peek()! }; -} - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); -}); +import { describe, expect, it } from "vitest"; +import { ReflectPointsCommand, RotatePointsCommand, ScalePointsCommand } from "./TransformCommands"; +import { addContour, addPoint, commandSourceFixture, point } from "../testUtils"; describe("RotatePointsCommand", () => { - it("should rotate points around origin", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 1, y: 0, pointType: "onCurve", smooth: false }); - const cmd = new RotatePointsCommand([p1], Math.PI / 2, { x: 0, y: 0 }); + it("rotates points around origin", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 1, y: 0 }); + const command = new RotatePointsCommand([pointId], Math.PI / 2, { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBeCloseTo(0, 5); - expect(points[0]!.y).toBeCloseTo(1, 5); + expect(point(source, pointId).x).toBeCloseTo(0, 5); + expect(point(source, pointId).y).toBeCloseTo(1, 5); }); - it("should not change state with empty point array", () => { - bridge.addContour(); - bridge.addPoint({ x: 1, y: 0, pointType: "onCurve", smooth: false }); - const cmd = new RotatePointsCommand([], Math.PI / 2, { x: 0, y: 0 }); + it("does not change state with empty point array", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 1, y: 0 }); + const command = new RotatePointsCommand([], Math.PI / 2, { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(1); - expect(points[0]!.y).toBe(0); + expect(point(source, pointId)).toMatchObject({ x: 1, y: 0 }); }); - it("should restore original positions on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new RotatePointsCommand([p1], Math.PI, { x: 0, y: 0 }); + it("restores original positions on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new RotatePointsCommand([pointId], Math.PI, { x: 0, y: 0 }); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(100); - expect(points[0]!.y).toBe(200); + expect(point(source, pointId)).toMatchObject({ x: 100, y: 200 }); }); - it("should re-apply rotation on redo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 1, y: 0, pointType: "onCurve", smooth: false }); - const cmd = new RotatePointsCommand([p1], Math.PI / 2, { x: 0, y: 0 }); + it("reapplies rotation on redo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 1, y: 0 }); + const command = new RotatePointsCommand([pointId], Math.PI / 2, { x: 0, y: 0 }); - cmd.execute(ctx()); - cmd.undo(ctx()); - cmd.redo(ctx()); + command.execute(ctx); + command.undo(ctx); + command.redo(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBeCloseTo(0, 5); - expect(points[0]!.y).toBeCloseTo(1, 5); + expect(point(source, pointId).x).toBeCloseTo(0, 5); + expect(point(source, pointId).y).toBeCloseTo(1, 5); }); - it("should have the correct name", () => { - const cmd = new RotatePointsCommand([], 0, { x: 0, y: 0 }); - expect(cmd.name).toBe("Rotate Points"); + it("has the correct name", () => { + const command = new RotatePointsCommand([], 0, { x: 0, y: 0 }); + expect(command.name).toBe("Rotate Points"); }); }); describe("ScalePointsCommand", () => { - it("should scale points from origin", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ScalePointsCommand([p1], 2, 2, { x: 0, y: 0 }); + it("scales points from origin", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ScalePointsCommand([pointId], 2, 2, { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(20); - expect(points[0]!.y).toBe(40); + expect(point(source, pointId)).toMatchObject({ x: 20, y: 40 }); }); - it("should scale non-uniformly", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ScalePointsCommand([p1], 2, 3, { x: 0, y: 0 }); + it("scales non-uniformly", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ScalePointsCommand([pointId], 2, 3, { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(20); - expect(points[0]!.y).toBe(60); + expect(point(source, pointId)).toMatchObject({ x: 20, y: 60 }); }); - it("should not change state with empty point array", () => { - bridge.addContour(); - bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ScalePointsCommand([], 2, 2, { x: 0, y: 0 }); + it("does not change state with empty point array", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ScalePointsCommand([], 2, 2, { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(10); - expect(points[0]!.y).toBe(20); + expect(point(source, pointId)).toMatchObject({ x: 10, y: 20 }); }); - it("should restore original positions on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new ScalePointsCommand([p1], 2, 2, { x: 0, y: 0 }); + it("restores original positions on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new ScalePointsCommand([pointId], 2, 2, { x: 0, y: 0 }); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(100); - expect(points[0]!.y).toBe(200); + expect(point(source, pointId)).toMatchObject({ x: 100, y: 200 }); }); - it("should have the correct name", () => { - const cmd = new ScalePointsCommand([], 1, 1, { x: 0, y: 0 }); - expect(cmd.name).toBe("Scale Points"); + it("has the correct name", () => { + const command = new ScalePointsCommand([], 1, 1, { x: 0, y: 0 }); + expect(command.name).toBe("Scale Points"); }); }); describe("ReflectPointsCommand", () => { - it("should reflect points horizontally", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ReflectPointsCommand([p1], "horizontal", { x: 0, y: 0 }); + it("reflects points horizontally", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ReflectPointsCommand([pointId], "horizontal", { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(10); - expect(points[0]!.y).toBe(-20); + expect(point(source, pointId)).toMatchObject({ x: 10, y: -20 }); }); - it("should reflect points vertically", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ReflectPointsCommand([p1], "vertical", { x: 0, y: 0 }); + it("reflects points vertically", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ReflectPointsCommand([pointId], "vertical", { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(-10); - expect(points[0]!.y).toBe(20); + expect(point(source, pointId)).toMatchObject({ x: -10, y: 20 }); }); - it("should not change state with empty point array", () => { - bridge.addContour(); - bridge.addPoint({ x: 10, y: 20, pointType: "onCurve", smooth: false }); - const cmd = new ReflectPointsCommand([], "horizontal", { x: 0, y: 0 }); + it("does not change state with empty point array", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 10, y: 20 }); + const command = new ReflectPointsCommand([], "horizontal", { x: 0, y: 0 }); - cmd.execute(ctx()); + command.execute(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(10); - expect(points[0]!.y).toBe(20); + expect(point(source, pointId)).toMatchObject({ x: 10, y: 20 }); }); - it("should restore original positions on undo", () => { - bridge.addContour(); - const p1 = bridge.addPoint({ x: 100, y: 200, pointType: "onCurve", smooth: false }); - const cmd = new ReflectPointsCommand([p1], "horizontal", { x: 0, y: 0 }); + it("restores original positions on undo", () => { + const { source, ctx } = commandSourceFixture(); + const contourId = addContour(source); + const pointId = addPoint(source, contourId, { x: 100, y: 200 }); + const command = new ReflectPointsCommand([pointId], "horizontal", { x: 0, y: 0 }); - cmd.execute(ctx()); - cmd.undo(ctx()); + command.execute(ctx); + command.undo(ctx); - const points = getAllPoints(bridge.getEditingSnapshot()); - expect(points[0]!.x).toBe(100); - expect(points[0]!.y).toBe(200); + expect(point(source, pointId)).toMatchObject({ x: 100, y: 200 }); }); - it("should have the correct name", () => { - const cmd = new ReflectPointsCommand([], "horizontal", { x: 0, y: 0 }); - expect(cmd.name).toBe("Reflect Points"); + it("has the correct name", () => { + const command = new ReflectPointsCommand([], "horizontal", { x: 0, y: 0 }); + expect(command.name).toBe("Reflect Points"); }); }); diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts index 7fe273a2..ec8ddfd1 100644 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts @@ -1,4 +1,5 @@ -import type { Point2D, PointId } from "@shift/types"; +import type { PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import { BaseCommand, type CommandContext } from "../core/Command"; import type { ReflectAxis } from "@/types/transform"; @@ -16,14 +17,14 @@ abstract class BaseTransformCommand extends BaseCommand { protected captureOriginalPositions(ctx: CommandContext): void { if (this.#originalPositions.size > 0 || this.pointIds.length === 0) return; - for (const point of ctx.glyph.points(this.pointIds)) { + for (const point of ctx.source.points(this.pointIds)) { this.#originalPositions.set(point.id, { x: point.x, y: point.y }); } } protected restoreOriginalPositions(ctx: CommandContext): void { for (const [id, pos] of this.#originalPositions) { - ctx.glyph.movePointTo(id, pos); + ctx.source.movePointTo(id, pos); } } } @@ -46,7 +47,7 @@ export class RotatePointsCommand extends BaseTransformCommand { execute(ctx: CommandContext): void { this.captureOriginalPositions(ctx); - ctx.glyph.rotate(this.pointIds, this.#angle, this.#origin); + ctx.source.rotate(this.pointIds, this.#angle, this.#origin); } undo(ctx: CommandContext): void { @@ -54,7 +55,7 @@ export class RotatePointsCommand extends BaseTransformCommand { } override redo(ctx: CommandContext): void { - ctx.glyph.rotate(this.pointIds, this.#angle, this.#origin); + ctx.source.rotate(this.pointIds, this.#angle, this.#origin); } } @@ -78,7 +79,7 @@ export class ScalePointsCommand extends BaseTransformCommand { execute(ctx: CommandContext): void { this.captureOriginalPositions(ctx); - ctx.glyph.scale(this.pointIds, this.#sx, this.#sy, this.#origin); + ctx.source.scale(this.pointIds, this.#sx, this.#sy, this.#origin); } undo(ctx: CommandContext): void { @@ -86,7 +87,7 @@ export class ScalePointsCommand extends BaseTransformCommand { } override redo(ctx: CommandContext): void { - ctx.glyph.scale(this.pointIds, this.#sx, this.#sy, this.#origin); + ctx.source.scale(this.pointIds, this.#sx, this.#sy, this.#origin); } } @@ -108,7 +109,7 @@ export class ReflectPointsCommand extends BaseTransformCommand { execute(ctx: CommandContext): void { this.captureOriginalPositions(ctx); - ctx.glyph.reflect(this.pointIds, this.#axis, this.#origin); + ctx.source.reflect(this.pointIds, this.#axis, this.#origin); } undo(ctx: CommandContext): void { @@ -116,13 +117,13 @@ export class ReflectPointsCommand extends BaseTransformCommand { } override redo(ctx: CommandContext): void { - ctx.glyph.reflect(this.pointIds, this.#axis, this.#origin); + ctx.source.reflect(this.pointIds, this.#axis, this.#origin); } } /** * Translates selected points so that a given anchor position lands on a - * target position. Computes the delta internally. Used for snap-to-position + * target position. Computes the delta internally. Used for move-to-position * and alignment workflows where the destination is absolute. */ export class MoveSelectionToCommand extends BaseTransformCommand { @@ -139,7 +140,7 @@ export class MoveSelectionToCommand extends BaseTransformCommand { execute(ctx: CommandContext): void { this.captureOriginalPositions(ctx); - ctx.glyph.moveSelectionTo(this.pointIds, this.#target, this.#anchor); + ctx.source.moveSelectionTo(this.pointIds, this.#target, this.#anchor); } undo(ctx: CommandContext): void { @@ -147,6 +148,6 @@ export class MoveSelectionToCommand extends BaseTransformCommand { } override redo(ctx: CommandContext): void { - ctx.glyph.moveSelectionTo(this.pointIds, this.#target, this.#anchor); + ctx.source.moveSelectionTo(this.pointIds, this.#target, this.#anchor); } } diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts index 7eaa100a..fa4f1d87 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts @@ -23,13 +23,14 @@ describe("Editor", () => { }); it("does not render the glyph once the run has cells and no slot edit", () => { - editor.textRun.buffer.insert(glyphCell("B", 66)); + editor.selectTool("text"); expect(editor.shouldRenderGlyph()).toBe(false); }); it("renders the glyph again when in-place editing a slot", () => { - const cell = glyphCell("B", 66); - editor.textRun.buffer.insert(cell); + editor.selectTool("text"); + const cell = glyphCell("S", 83); + editor.textRun.insert(cell); editor.setGlyphFocus({ runId: editor.textRun.id, cellId: cell.id }); expect(editor.shouldRenderGlyph()).toBe(true); }); @@ -48,24 +49,25 @@ describe("Editor", () => { describe("text-run owner = main glyph (not active editing glyph)", () => { it("keeps the run's cells when switching back to Text after a slot drill-in", () => { // A is the main glyph (the one the user "opened from the grid"). - const ownerKey = editor.font.glyphName(65); - editor.setGlyphHandle({ glyphName: ownerKey, unicode: 65 }); + const owner = editor.font.glyphHandleForUnicode(65)!; + const ownerKey = owner.name; + editor.setRootGlyphHandle(owner); editor.selectTool("text"); - editor.textRun.insert(glyphCell("B", 66)); + editor.textRun.insert(glyphCell("S", 83)); expect(editor.textRun.buffer.cells).toHaveLength(2); - // Drill into slot 1 (the B): mirrors what TextRunEdit does on dblclick. + // Drill into slot 1 (the S): mirrors what TextRunEdit does on dblclick. editor.selectTool("select"); - const bCell = editor.textRun.buffer.cells[1]; - expect(bCell.kind).toBe("glyph"); - editor.setGlyphFocus({ runId: editor.textRun.id, cellId: bCell.id }); - expect(editor.getActiveGlyphName()).toBe("B"); + const sCell = editor.textRun.buffer.cells[1]; + expect(sCell.kind).toBe("glyph"); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: sCell.id }); + expect(editor.getActiveGlyphName()).toBe("S"); // Main glyph (run owner) hasn't moved. - expect(editor.getGlyphHandle()!.glyphName).toBe(ownerKey); + expect(editor.rootGlyphHandle!.name).toBe(ownerKey); // Toggle back to Text. The run should still be the A-keyed run, with - // its cells preserved — not a fresh B-keyed run. + // its cells preserved — not a fresh S-keyed run. editor.selectTool("text"); expect(editor.textRun.buffer.cells).toHaveLength(2); @@ -74,27 +76,27 @@ describe("Editor", () => { glyphName: ownerKey, codepoint: 65, }); - expect(editor.textRun.buffer.cells[1]).toBe(bCell); + expect(editor.textRun.buffer.cells[1]).toBe(sCell); }); }); describe("glyph focus placement", () => { it("recomputes drawOffset from the focused cell after inserting a linebreak before it", () => { - const ownerKey = editor.font.glyphName(65); - editor.setGlyphHandle({ glyphName: ownerKey, unicode: 65 }); + const owner = editor.font.glyphHandleForUnicode(65)!; + editor.setRootGlyphHandle(owner); editor.selectTool("text"); - const b = glyphCell("B", 66); - editor.textRun.insert(b); - editor.setGlyphFocus({ runId: editor.textRun.id, cellId: b.id }); + const s = glyphCell("S", 83); + editor.textRun.insert(s); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: s.id }); const firstLineOrigin = editor.glyphPlacement?.focused.editOrigin; editor.textRun.buffer.placeCaret(1); editor.textRun.insert(linebreakCell()); editor.selectTool("select"); - const secondLineOrigin = editor.textRun.$layout.peek()?.editOriginForCell(b.id); - expect(editor.focusedGlyph?.anchor.cellId).toBe(b.id); - expect(editor.focusedGlyph?.glyph.glyphName).toBe("B"); + const secondLineOrigin = editor.textRun.$layout.peek()?.editOriginForCell(s.id); + expect(editor.focusedGlyph?.anchor.cellId).toBe(s.id); + expect(editor.focusedGlyph?.glyph.name).toBe("S"); expect(secondLineOrigin?.y).toBe(editor.textRun.$layout.peek()?.lines[1].y); expect(editor.glyphPlacement?.focused.editOrigin).toEqual(secondLineOrigin); expect(editor.drawOffset).toEqual(secondLineOrigin); @@ -103,9 +105,9 @@ describe("Editor", () => { it("clears derived placement when the focused cell is deleted", () => { editor.selectTool("text"); - const b = glyphCell("B", 66); - editor.textRun.insert(b); - editor.setGlyphFocus({ runId: editor.textRun.id, cellId: b.id }); + const s = glyphCell("S", 83); + editor.textRun.insert(s); + editor.setGlyphFocus({ runId: editor.textRun.id, cellId: s.id }); editor.textRun.buffer.placeCaret(2); editor.textRun.delete(); @@ -116,9 +118,9 @@ describe("Editor", () => { }); it("opens direct glyphs through the implicit editor run", () => { - editor.openGlyph({ glyphName: "S", unicode: 83 }); + editor.openGlyph({ name: "S", unicode: 83 }); - expect(editor.focusedGlyph?.glyph.glyphName).toBe("S"); + expect(editor.focusedGlyph?.glyph.name).toBe("S"); expect(editor.textRuns.resolveAnchor(editor.focusedGlyph!.anchor)).toEqual( editor.focusedGlyph, ); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index d90bef54..6d4fe74c 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -1,47 +1,33 @@ import type { HandleState } from "@/types/graphics"; import { Gpu } from "@/lib/graphics/backends/Gpu"; -import type { CursorType, SnapPreferences, ToolRegistryItem } from "@/types/editor"; -import type { - Point2D, - Rect2D, - PointId, - AnchorId, - ContourId, - Contour, - Point, - AxisLocation, - GlyphVariationData, - CompositeGlyph, -} from "@shift/types"; -import type { Glyph } from "@/lib/model/Glyph"; -import { blockToPath2D } from "@/lib/model/GlyphView"; -import { interpolate, normalize } from "@/lib/interpolation/interpolate"; +import type { CursorType, ToolRegistryItem } from "@/types/editor"; +import type { PointId, AnchorId, ContourId, Source, SourceId } from "@shift/types"; +import type { AxisLocation } from "@/types/variation"; +import type { Glyph, GlyphSource } from "@/lib/model/Glyph"; +import { axisLocationFromLocation, emptyAxisLocation } from "@/lib/variation/location"; import type { ToolName, ActiveToolState } from "../tools/core"; import type { SegmentId, SegmentIndicator } from "@/types/indicator"; import type { HitResult, MiddlePointHit, ContourEndpointHit, HoverResult } from "@/types/hitResult"; import { ToolManager } from "../tools/core/ToolManager"; -import { SnapshotCommand } from "../commands/primitives/SnapshotCommand"; -import { SetNodePositionsCommand } from "../commands/primitives/SetNodePositionsCommand"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; -import { Segment, type SegmentHitResult } from "@/lib/model/Segment"; -import { Bounds, Vec2 } from "@shift/geo"; -import { Contours } from "@shift/font"; +import { Segment, type SegmentHitResult } from "@shift/glyph-state"; +import { Bounds, Point2D, Rect2D, Vec2 } from "@shift/geo"; import type { BoundingBoxHitResult } from "@/types/boundingBox"; import type { Coordinates } from "@/types/coordinates"; import { ViewportManager } from "./managers"; import { displayAdvance } from "@/lib/utils/unicode"; -import { NativeBridge } from "@/bridge"; import { CommandHistory, SetLeftSidebearingCommand, SetRightSidebearingCommand, SetXAdvanceCommand, NudgePointsCommand, - SetActiveContourCommand, ReverseContourCommand, SplitSegmentCommand, UpgradeLineToCubicCommand, + BooleanOperationCommand, + CutCommand, + PasteCommand, } from "../commands"; import { RotatePointsCommand, @@ -63,8 +49,13 @@ import { type Effect, type Signal, type WritableSignal, -} from "../reactive/signal"; -import { Clipboard, resolveClipboardContent, type SystemClipboard } from "../clipboard"; +} from "../signals/signal"; +import { + Clipboard, + ClipboardContent, + ClipboardSelection, + type SystemClipboard, +} from "../clipboard"; import { cursorToCSS } from "../styles/cursor"; import { DEFAULT_THEME } from "./rendering/Theme"; import { hitTestBoundingBox, isBoundingBoxVisibleAtZoom } from "./hit/boundingBox"; @@ -79,57 +70,37 @@ import { BoundingBox, ControlLines, Segments, - SnapLines, DebugOverlays as DebugOverlaysIndicator, Anchors, } from "./rendering/indicators"; import { SCREEN_HIT_RADIUS } from "./rendering/constants"; import { getVisibleSceneBounds } from "./rendering/visibleSceneBounds"; import type { FocusZone } from "@/types/focus"; -import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; +import type { GlyphHandle } from "@shared/bridge/BridgeApi"; import type { DebugOverlays } from "@shared/ipc/types"; import type { TemporaryToolOptions } from "@/types/editor"; import { Selection } from "@/types/selection"; import { Font } from "../model/Font"; import type { Modifiers } from "../tools/core/GestureDetector"; -import type { - DragSnapSession, - DragSnapSessionConfig, - RotateSnapSession, - SnapIndicator, -} from "./snapping/types"; -import { SnapManager } from "./managers/SnapManager"; import { TextRuns } from "@/lib/text/TextRuns"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; import { glyphCell, Positioner } from "@/lib/text/layout"; import type { GlyphAnchor } from "@/lib/text/layout"; -import { SnapPreferencesSchema, TextRunModuleSchema } from "@shift/validation"; +import { TextRunModuleSchema } from "@shift/validation"; import type { TextRunModule } from "@/persistence/types"; -interface AppSettings { - snap: SnapPreferences; -} - -const defaultAppSettings: AppSettings = { - snap: { - enabled: true, - angle: true, - metrics: true, - pointToPoint: true, - angleIncrementDeg: 45, - pointRadiusPx: 8, - }, -}; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; import { EventEmitter } from "./lifecycle"; import { StateRegistry, type ShiftState, type ShiftStateOptions } from "@/lib/state/ShiftState"; -import type { LineSegment } from "@/types/segments"; -import type { GlyphDraft } from "@/types/draft"; +import type { LineSegment } from "@shift/glyph-state"; +import type { ShiftBridge } from "@shift/bridge"; +import { Contour, type Point } from "@shift/glyph-state"; +import { SourceEditDraft, type SourceEditSubject } from "./SourceEditDraft"; interface EditorOptions { - bridge: NativeBridge; + bridge: ShiftBridge; clipboard: SystemClipboard; } @@ -142,7 +113,7 @@ interface GlyphPlacement { * Central orchestrator for the glyph editing surface. * * Editor owns and wires together every subsystem: viewport (UPM/screen - * transforms), selection, hover, command history, snapping, clipboard, + * transforms), selection, hover, command history, clipboard, * tool management, and rendering (via Viewport). It is passed * directly to tools and behaviors. * @@ -169,13 +140,11 @@ export class Editor { #hover: HoverManager; #renderer: Viewport; #edgePan: EdgePanManager; - #snapManager: SnapManager; #guides = new Guides(); #boundingBox = new BoundingBox(); #controlLines = new ControlLines(); #segments = new Segments(); - #snapLines = new SnapLines(); #debugOverlaysIndicator = new DebugOverlaysIndicator(); #anchors = new Anchors(); #handles = new Handles(); @@ -191,14 +160,15 @@ export class Editor { #viewport: ViewportManager; #commandHistory: CommandHistory; - #bridge: NativeBridge; - #$glyph: ComputedSignal; - // Variation data for the active glyph. Cached on session start so slider - // scrubs avoid a per-tick FFI. Was previously held in a useRef inside - // useApplyVariation; moved here so the cache lifecycle is tied to the - // edit session (not React render timing) and `open()` can apply the - // current location synchronously after starting the session. - #activeVariationData: GlyphVariationData | null = null; + #bridge: ShiftBridge; + + #$editingGlyph: WritableSignal; + #$rootGlyphHandle: WritableSignal; + #$activeContour: WritableSignal; + #$designLocation: WritableSignal; + #$activeSourceId: WritableSignal; + #$activeSource: ComputedSignal; + #$activeGlyphSource: ComputedSignal; #$segmentIndex: ComputedSignal>; #staticEffect: Effect; @@ -209,7 +179,6 @@ export class Editor { #events: EventEmitter; #stateRegistry: StateRegistry; #textRuns: TextRuns; - #mainGlyph: GlyphHandle | null = null; #$glyphFinderOpen: WritableSignal; #zone: FocusZone = "canvas"; @@ -222,8 +191,6 @@ export class Editor { #cursor: WritableSignal; #currentModifiers: WritableSignal; #isHoveringNode: ComputedSignal; - #settings: ShiftState; - #snapIndicator: WritableSignal; #debugOverlays: WritableSignal; #toolState: { app: Map; @@ -241,10 +208,31 @@ export class Editor { this.#bridge = options.bridge; this.font = new Font(this.#bridge); - this.#$glyph = computed(() => this.#bridge.$glyph.value as Glyph | null); + + this.#$activeContour = signal(null); + this.#$editingGlyph = signal(null); + this.#$rootGlyphHandle = signal(null); + this.#$designLocation = signal(emptyAxisLocation()); + this.#$activeSourceId = signal(null); + + this.#$activeSource = computed(() => { + const sourceId = this.#$activeSourceId.value; + if (sourceId) return this.font.source(sourceId); + + return this.font.sourceAt(this.#$designLocation.value); + }); + + this.#$activeGlyphSource = computed(() => { + const glyph = this.#$editingGlyph.value; + if (!glyph) return null; + const source = this.#$activeSource.value; + if (!source) return null; + + return this.font.glyphSource(glyph.handle, source); + }); this.#$segmentIndex = computed(() => { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return new Map(); const segmentsById = new Map(); for (const { segment } of glyph.segments()) { @@ -253,7 +241,7 @@ export class Editor { return segmentsById; }); - this.#commandHistory = new CommandHistory(this.#$glyph); + this.#commandHistory = new CommandHistory(this.#$activeGlyphSource); this.#previewMode = signal(false); this.#cursor = signal("default"); @@ -266,20 +254,6 @@ export class Editor { metaKey: false, }); - this.#settings = this.registerState({ - id: "settings", - scope: "app", - initial: () => defaultAppSettings, - serialize: (v) => v, - deserialize: (json) => { - const parsed = json as Record; - return { - snap: SnapPreferencesSchema.parse(parsed.snap ?? defaultAppSettings.snap), - }; - }, - }); - - this.#snapIndicator = signal(null); this.#debugOverlays = signal({ tightBounds: false, hitRadii: false, @@ -294,15 +268,9 @@ export class Editor { this.#$glyphFinderOpen = signal(false); - this.selection = new Selection(this.#$glyph); + this.selection = new Selection(this.#$editingGlyph); this.#hover = new HoverManager(); this.#edgePan = new EdgePanManager(this); - this.#snapManager = new SnapManager( - this.#$glyph, - () => this.font.getMetrics(), - () => this.settings.snap, - (px) => this.#viewport.screenToUpmDistance(px), - ); this.#isHoveringNode = computed( () => this.#hover.hoveredPointId.value !== null || @@ -318,13 +286,8 @@ export class Editor { this.#events = new EventEmitter(); this.#toolManager = new ToolManager(this); this.#renderer = new Viewport(this); - this.#clipboard = new Clipboard({ - glyph: this.#$glyph, - selection: this.selection, - commands: this.#commandHistory, - clipboard: options.clipboard, - }); - this.#textRuns = new TextRuns(this.font, new Positioner()); + this.#clipboard = new Clipboard(options.clipboard); + this.#textRuns = new TextRuns(this.font, new Positioner(), this.#$designLocation); const textRunPersistence = this.registerState({ id: "text-run", @@ -369,17 +332,12 @@ export class Editor { ); this.#staticEffect = effect(() => { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (glyph) { glyph.contours; glyph.anchors; } - // Slider scrubs need to re-trigger the scene redraw even when the - // active glyph is a pure composite (whose own contour signals don't - // fire because useApplyVariation short-circuits on null variationData). - // The redraw path then walks font.glyph(name).componentContours() to - // pick up freshly-interpolated component shapes. - this.font.$variationLocation.value; + this.#$designLocation.value; this.#$drawOffset.value; this.#$focusedGlyph.value; this.$activeToolState.value; @@ -407,7 +365,7 @@ export class Editor { }); this.#overlayEffect = effect(() => { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (glyph) { glyph.contours; glyph.anchors; @@ -421,7 +379,6 @@ export class Editor { this.#hover.hoveredBoundingBoxHandle.value; this.#previewMode.value; this.#handlesVisible.value; - this.#snapIndicator.value; this.$activeToolState.value; this.#renderer.requestOverlayRedraw(); }); @@ -533,7 +490,7 @@ export class Editor { if (glyph && this.shouldRenderGlyph() && !previewMode) { const unicode = Number.isFinite(glyph.unicode) ? glyph.unicode : null; const advance = displayAdvance(glyph.xAdvance, glyph.name, unicode); - this.#guides.draw(canvas, this.font.getMetrics(), advance); + this.#guides.draw(canvas, this.font.metrics, advance); } this.#toolManager.renderBackground(canvas); @@ -550,22 +507,9 @@ export class Editor { const handlesVisible = this.handlesVisible; if (glyph && this.shouldRenderGlyph()) { - glyph.drawOutline(canvas); - if (previewMode) glyph.draw(canvas); - - // Composite components — drawn from the read-only GlyphView so they - // re-interpolate with the slider. Direct $location.value read keeps - // this re-render path alive even if a base GlyphView is evicted from - // the LRU mid-session (see lib/reactive/docs/DOCS.md). - this.font.$variationLocation.value; - const view = this.font.glyph(glyph.name); - if (view) { - for (const block of view.componentContours()) { - const path = blockToPath2D(block); - canvas.strokePath(path, canvas.theme.glyph.stroke, canvas.theme.glyph.widthPx); - if (previewMode) canvas.fillPath(path, canvas.theme.glyph.fill); - } - } + const path = glyph.outline(this.#$designLocation).path; + canvas.strokePath(path, canvas.theme.glyph.stroke, canvas.theme.glyph.widthPx); + if (previewMode) canvas.fillPath(path, canvas.theme.glyph.fill); } if (!previewMode && glyph && this.shouldRenderGlyph()) { @@ -590,18 +534,22 @@ export class Editor { this.#toolManager.renderScene(canvas); - // Text run draws regardless of active glyph state — it's the run content - // (typed glyphs, selection, caret) the user sees while in the Text tool - // or hovering text-run cells from Select. World-space → drawOffset-local - // compensation lives inside the renderer. - this.#textRunRenderer.draw(canvas, this.textRun, this.font, this.drawOffset, this.focusedGlyph); + this.#textRunRenderer.draw( + canvas, + this.textRun, + this.font, + this.#$designLocation, + this.drawOffset, + this.focusedGlyph, + ); if (!previewMode && handlesVisible && glyph && this.shouldRenderGlyph()) { + const geometry = glyph.geometryAt(this.#$designLocation.peek()); const viewport = this.getViewportTransform(); const drawOffset = this.drawOffset; const sceneBounds = getVisibleSceneBounds(viewport, 64); - this.#controlLines.draw(canvas, glyph, (from, to) => { + this.#controlLines.draw(canvas, geometry.contours, (from, to) => { const minX = Math.min(from.x, to.x) + drawOffset.x; const maxX = Math.max(from.x, to.x) + drawOffset.x; const minY = Math.min(from.y, to.y) + drawOffset.y; @@ -615,7 +563,7 @@ export class Editor { }); const renderedOnGpu = this.#handles.draw( - glyph, + geometry.contours, { getHandleState: (id) => this.getHandleState(id) }, viewport, drawOffset, @@ -627,7 +575,8 @@ export class Editor { }); } - this.#anchors.draw(canvas, glyph, (id) => this.getAnchorHandleState(id)); + const anchors = geometry.anchors; + this.#anchors.draw(canvas, anchors, (id) => this.getAnchorHandleState(id)); } else { this.#handles.clear(); } @@ -668,12 +617,8 @@ export class Editor { } } - // UPM-space pass: snap lines + tool overlay + // UPM-space pass: tool overlay this.#renderer.beginUpmSpace(canvas); - const indicator = this.snapIndicator; - if (indicator) { - this.#snapLines.draw(canvas, indicator); - } this.#toolManager.renderOverlay(canvas); canvas.ctx.restore(); } @@ -752,71 +697,13 @@ export class Editor { this.#currentModifiers.set(modifiers); } - get settings(): AppSettings { - return this.#settings.value; - } - - public createDragSnapSession(config: DragSnapSessionConfig): DragSnapSession { - return this.#snapManager.createDragSession(config); - } - - public createRotateSnapSession(): RotateSnapSession { - return this.#snapManager.createRotateSession(); - } - - /** - * Sets or clears the snap indicator rendered on the overlay canvas. - * - * Tools call this with a result from `DragSnapSession.snap()` during a drag, - * and with `null` when the drag ends or the tool deactivates. Forgetting to - * clear leaves a stale indicator on screen. - */ - public setSnapIndicator(indicator: SnapIndicator | null): void { - this.#snapIndicator.set(indicator); - } - - public get snapIndicator(): SnapIndicator | null { - return this.#snapIndicator.value; - } - - /** @knipclassignore */ - public get $snapIndicator(): Signal { - return this.#snapIndicator; - } - - public createDraft(): GlyphDraft { - const glyph = this.#bridge.$glyph.peek(); - if (!glyph) { - throw new Error("Cannot create draft without an active glyph"); + public beginSourceEditDraft(subject: SourceEditSubject): SourceEditDraft { + const glyphSource = this.#$activeGlyphSource.peek(); + if (!glyphSource) { + throw new Error("Cannot begin a source edit draft without an active glyph source"); } - const base = glyph.toSnapshot(); - let updates: NodePositionUpdateList = []; - let finished = false; - - return { - base, - setPositions: (u) => { - if (finished) return; - updates = u; - glyph.apply(u); - }, - finish: (label) => { - if (finished) return; - finished = true; - if (updates.length === 0) return; - - this.#bridge.sync(updates); - this.#commandHistory.record( - SetNodePositionsCommand.fromBaseGlyphAndUpdates(label, base, updates)!, - ); - }, - discard: () => { - if (finished) return; - finished = true; - if (updates.length > 0) glyph.apply(base); - }, - }; + return new SourceEditDraft(glyphSource, this.#commandHistory, subject); } public withBatch(label: string, fn: () => TResult): TResult { @@ -921,29 +808,53 @@ export class Editor { return this.#renderer.gpuHandleContext; } - /** - * Open `glyphName` for editing. Returns the editable Glyph (or `null` if - * the session failed to start). After construction the editable Glyph - * carries the master's stored coordinates — but the user came from the - * grid where they saw the glyph **interpolated** at the current variation - * location. We close that gap by applying the current location to the - * active glyph immediately, so the canvas matches what the user clicked. - * Subsequent slider scrubs go through `applyVariation`. - */ - public open(handle: GlyphHandle): Glyph | null { - const currentGlyphName = this.#bridge.getEditingGlyphName(); - if (currentGlyphName === handle.glyphName) return this.#$glyph.peek(); + public getGlyph(handle: GlyphHandle): Glyph | null { + const source = + this.#$activeSource.peek() ?? this.font.sourceAtOrDefault(this.#$designLocation.peek()); + if (!source) return null; + + const glyph = this.font.glyph(handle); + if (!glyph) return null; + + this.#$editingGlyph.set(glyph); + this.#updateViewportMetrics(); - this.#bridge.startEditSession(handle); - this.#activeVariationData = this.font.getGlyphVariationData(handle.glyphName); - this.#applyCurrentVariationToActive(); - return this.#$glyph.peek(); + return glyph; + } + + public setRootGlyphHandle(handle: GlyphHandle | null): void { + this.#$rootGlyphHandle.set(handle); + } + + public get rootGlyphHandle(): GlyphHandle | null { + return this.#$rootGlyphHandle.value; + } + + public getActiveGlyphName(): string | null { + return this.#$editingGlyph.value?.name ?? null; + } + + public editGlyphSource(handle: GlyphHandle, sourceId: SourceId): GlyphSource | null { + this.setRootGlyphHandle(handle); + const glyph = this.getGlyph(handle); + const source = this.font.source(sourceId); + if (!glyph || !source) return null; + + if (this.#bridge.hasEditSession()) { + this.#bridge.endEditSession(); + } + this.#bridge.startEditSession(handle, source.id); + + this.#$activeSourceId.set(source.id); + return this.font.glyphSource(handle, source); } public openGlyph(handle: GlyphHandle): Glyph | null { - const anchor = this.#textRuns.editorRun().setSingleGlyph(handle); + this.setRootGlyphHandle(handle); + const runs = this.#textRuns.editorRun(); + const anchor = runs.setSingleGlyph(handle); this.setGlyphFocus(anchor); - return this.#$glyph.peek(); + return this.#$editingGlyph.peek(); } /** @@ -966,7 +877,7 @@ export class Editor { batch(() => { this.#$glyphAnchor.set(anchor); - this.open(focused.glyph); + this.getGlyph(focused.glyph); this.setPreviewMode(false); }); } @@ -985,35 +896,58 @@ export class Editor { /** Ends the current editing session. */ public close(): void { - this.#bridge.endEditSession(); - this.#activeVariationData = null; + if (this.#bridge.hasEditSession()) { + this.#bridge.endEditSession(); + } + this.#$editingGlyph.set(null); + this.#$activeContour.set(null); } - /** - * Slider-scrub entry point. Sets the shared `$variationLocation` (which - * the grid and canvas component-drawing both read) and pushes interpolated - * values into the active editable Glyph using the variation data cached - * on session start — no per-tick FFI. - */ - public applyVariation(location: AxisLocation): void { - this.font.setVariationLocation(location); - this.#applyCurrentVariationToActive(); + public get $designLocation(): Signal { + return this.#$designLocation; } - /** - * Push interpolated values at the current `$variationLocation` into the - * active editable Glyph. No-op if there's no cached variation data - * (composite with no own deltas, non-variable font, or no active glyph) - * — the canvas's reactive component-drawing handles those cases. - */ - #applyCurrentVariationToActive(): void { - const glyph = this.#$glyph.peek(); - const data = this.#activeVariationData; - if (!glyph || !data) return; + public get designLocation(): AxisLocation { + return this.#$designLocation.value; + } + + public get $activeSourceId(): Signal { + return this.#$activeSourceId; + } + + public get activeSourceId(): SourceId | null { + return this.#$activeSourceId.value; + } + + public get activeSource(): Source | null { + return this.#$activeSource.value; + } + + public get activeGlyphSource(): GlyphSource | null { + return this.#$activeGlyphSource.value; + } + + public setDesignLocation(location: AxisLocation): void { + batch(() => { + this.#$designLocation.set(location); + const source = this.font.sourceAt(location); + this.#$activeSourceId.set(source?.id ?? null); + }); + } + + public selectSource(sourceId: SourceId): void { + const source = this.font.source(sourceId); + if (!source) return; + + batch(() => { + const location = axisLocationFromLocation(source.location); + this.#$designLocation.set(location); + this.#$activeSourceId.set(source.id); + }); + } - const axes = this.font.getAxes(); - const values = interpolate(data, normalize(this.font.$variationLocation.peek(), axes)); - glyph.applyValues(values); + public clearActiveSource(): void { + this.#$activeSourceId.set(null); } public get textRuns(): TextRuns { @@ -1027,8 +961,9 @@ export class Editor { /** Resolve a unicode codepoint to a glyph cell and insert into the active text run. */ public insertTextCodepoint(codepoint: number): void { - const glyphName = this.font.glyphName(codepoint); - this.textRun.insert(glyphCell(glyphName, codepoint)); + const handle = this.font.glyphHandleForUnicode(codepoint); + if (!handle) return; + this.textRun.insert(glyphCell(handle.name, codepoint)); } /** @knipclassignore Indirectly consumed through Viewport. */ @@ -1041,10 +976,6 @@ export class Editor { return this.#$focusedGlyph.value?.anchor.runId === run.id; } - public getGlyphCompositeComponents(glyphName: string): CompositeGlyph | null { - return this.font.composites(glyphName); - } - public getToolState(scope: ToolStateScope, toolId: string, key: string): unknown { return this.#getToolScopeMap(scope).get(this.#toolStateKey(toolId, key)); } @@ -1089,24 +1020,7 @@ export class Editor { } public get glyph(): Signal { - return this.#$glyph; - } - - public getActiveGlyphUnicode(): number | null { - return this.#bridge.getEditingUnicode(); - } - - public getActiveGlyphName(): string | null { - return this.#bridge.getEditingGlyphName(); - } - - public setGlyphHandle(handle: GlyphHandle | null): void { - this.#mainGlyph = handle; - this.#textRuns.switchTo(handle?.glyphName ?? null); - } - - public getGlyphHandle(): GlyphHandle | null { - return this.#mainGlyph; + return this.#$editingGlyph; } public get commandHistory(): CommandHistory { @@ -1130,7 +1044,7 @@ export class Editor { return this.#commandHistory; } - public get bridge(): NativeBridge { + public get bridge(): ShiftBridge { return this.#bridge; } @@ -1183,7 +1097,7 @@ export class Editor { } public setXAdvance(width: number): void { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return; if (glyph.xAdvance === width) return; @@ -1191,13 +1105,11 @@ export class Editor { } public setLeftSidebearing(value: number): void { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return; - // Command path — use bezier-accurate bbox so the delta reflects the true - // current LSB including curve extension, not the cheap point-based - // approximation used for sidebar display. - const bbox = glyph.bbox; + const outline = glyph.outline(this.#$designLocation); + const bbox = outline.bounds; if (!bbox) return; const delta = Math.round(value) - Math.round(bbox.min.x); @@ -1210,10 +1122,10 @@ export class Editor { } public setRightSidebearing(value: number): void { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return; - const bbox = glyph.bbox; + const bbox = glyph.outline(this.#$designLocation).bounds; if (!bbox) return; const currentRsb = glyph.xAdvance - bbox.max.x; @@ -1226,11 +1138,10 @@ export class Editor { ); } - public updateMetricsFromFont(): void { - const metrics = this.font.getMetrics(); + #updateViewportMetrics(): void { + const metrics = this.font.metrics; this.#viewport.upm = metrics.unitsPerEm; this.#viewport.descender = metrics.descender; - this.requestRedraw(); } public get screenMousePosition(): Signal { @@ -1359,17 +1270,25 @@ export class Editor { * descender are NOT updated here -- call `updateMetricsFromFont()` to sync. */ public loadFont(filePath: string): void { - if (this.#bridge.hasSession()) { + if (this.#bridge.hasEditSession()) { this.close(); } + this.font.load(filePath); + this.setDesignLocation(this.font.defaultLocation()); this.#events.emit("fontLoaded", { font: this.font }); - const initial = { glyphName: this.font.glyphName(65), unicode: 65 }; - this.setGlyphHandle(initial); - this.openGlyph(initial); } - public async saveFont(filePath: string): Promise { + public resetFont(): void { + if (this.#bridge.hasEditSession()) { + this.close(); + } + + this.font.reset(); + this.setDesignLocation(emptyAxisLocation()); + } + + public async saveFont(filePath: string): Promise { return this.font.save(filePath); } @@ -1406,26 +1325,56 @@ export class Editor { } public deleteSelectedPoints(): void { - const glyph = this.#$glyph.value; - if (!glyph) return; + const glyphSource = this.#$activeGlyphSource.value; + if (!glyphSource) return; const selectedIds = [...this.selection.pointIds]; if (selectedIds.length > 0) { - glyph.removePoints(selectedIds); + glyphSource.removePoints(selectedIds); this.selection.clear(); } } public async copy(): Promise { - return this.#clipboard.copy(); + const content = this.#selectedClipboardContent(); + if (!content || content.contours.length === 0) return false; + + const glyph = this.#$editingGlyph.peek(); + if (!glyph) return false; + + return this.#clipboard.write(content, { sourceGlyph: glyph.name }); } public async cut(): Promise { - return this.#clipboard.cut(); + const content = this.#selectedClipboardContent(); + if (!content || content.contours.length === 0) return false; + + const glyph = this.#$editingGlyph.peek(); + if (!glyph) return false; + + const written = await this.#clipboard.write(content, { + sourceGlyph: glyph.name, + }); + if (!written) return false; + + const pointIds = this.#selectedClipboardPointIds(); + this.#commandHistory.execute(new CutCommand(pointIds)); + this.selection.clear(); + + return true; } public async paste(): Promise { - return this.#clipboard.paste(); + const result = await this.#clipboard.read(); + if (result.kind !== "glyph" || result.content.contours.length === 0) return; + + this.selection.clear(); + const command = new PasteCommand(result.content, { offset: this.#clipboard.nextPasteOffset() }); + this.#commandHistory.execute(command); + + if (command.createdPointIds.length > 0) { + this.selection.select(command.createdPointIds.map((id) => ({ kind: "point", id }))); + } } #selectionCenter(): Point2D | null { @@ -1433,6 +1382,23 @@ export class Editor { return bounds ? Bounds.center(bounds) : null; } + #selectedClipboardContent(): ClipboardContent | null { + const source = this.#$activeGlyphSource.peek(); + if (!source) return null; + + const selection = ClipboardSelection.fromSelection(this.selection); + if (selection.pointIds.length === 0) return null; + + return selection.contentFrom(source); + } + + #selectedClipboardPointIds(): PointId[] { + const selection = ClipboardSelection.fromSelection(this.selection); + if (selection.pointIds.length === 0) return []; + + return [...selection.pointIds]; + } + /** @param angle - Rotation in radians. */ public rotateSelection(angle: number, origin?: Point2D): void { const pointIds = [...this.selection.pointIds]; @@ -1512,29 +1478,33 @@ export class Editor { } public getActiveContourId(): ContourId | null { - const id = this.#bridge.getActiveContourId(); - if (id == null) return null; - return id; + return this.#$activeContour.value ?? null; + } + + public setActiveContour(contourId: ContourId | null): void { + this.#$activeContour.set(contourId); + } + + public clearActiveContour(): void { + this.#$activeContour.set(null); } public getActiveContour(): Contour | null { const activeContourId = this.getActiveContourId(); if (!activeContourId) return null; - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return null; return glyph.contour(activeContourId) ?? null; } public continueContour(contourId: ContourId, fromStart: boolean, pointId: PointId): void { - this.#commandHistory.withBatch("Continue Contour", () => { - this.#commandHistory.execute(new SetActiveContourCommand(contourId)); - if (fromStart) { - this.#commandHistory.execute(new ReverseContourCommand(contourId)); - } - this.selection.select([{ kind: "point", id: pointId }]); - }); + this.#$activeContour.set(contourId); + if (fromStart) { + this.#commandHistory.execute(new ReverseContourCommand(contourId)); + } + this.selection.select([{ kind: "point", id: pointId }]); } public splitSegment(segment: Segment, t: number): PointId { @@ -1565,28 +1535,28 @@ export class Editor { contourIdB: ContourId, operation: "union" | "subtract" | "intersect" | "difference", ): void { - const before = this.#bridge.getSnapshot(); - this.#bridge.applyBooleanOp(contourIdA, contourIdB, operation); - const after = this.#bridge.getSnapshot(); - this.#commandHistory.record(new SnapshotCommand(`Boolean ${operation}`, before, after)); + this.#commandHistory.execute(new BooleanOperationCommand(contourIdA, contourIdB, operation)); } private getPointAt(coords: Coordinates): Point | null { - const glyph = this.#$glyph.value; - if (!glyph) return null; + const glyphSource = this.#$activeGlyphSource.value; + if (!glyphSource) return null; - return glyph.getPointAt(coords.glyphLocal, this.hitRadius); + return ( + glyphSource.allPoints.find((point) => Vec2.dist(point, coords.glyphLocal) < this.hitRadius) ?? + null + ); } private getAnchorAt( coords: Coordinates, ): { id: AnchorId; name: string | null; x: number; y: number } | null { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return null; for (const anchor of glyph.anchors) { if (Vec2.dist(anchor, coords.glyphLocal) < this.hitRadius) { - return { id: anchor.id, name: anchor.name, x: anchor.x, y: anchor.y }; + return { id: anchor.id, name: anchor.name ?? null, x: anchor.x, y: anchor.y }; } } @@ -1594,7 +1564,7 @@ export class Editor { } private getSegmentAt(coords: Coordinates): SegmentHitResult | null { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return null; let bestHit: SegmentHitResult | null = null; @@ -1661,7 +1631,7 @@ export class Editor { * or extend a contour. */ private getContourEndpointAt(coords: Coordinates): ContourEndpointHit | null { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return null; for (const contour of glyph.contours) { @@ -1699,7 +1669,7 @@ export class Editor { * selection is still in preview mode. */ public getSelectionBoundingRect(): Rect2D | null { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; const selectedPointIds = this.selection.$pointIds.peek(); const selectionMode = this.selection.$mode.peek(); @@ -1753,28 +1723,19 @@ export class Editor { } public getAllPoints(): Point[] { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return []; return glyph.allPoints; } public duplicateSelection(): PointId[] { - const glyph = this.#$glyph.value; - if (!glyph) return []; - - const selectedPointIds = [...this.selection.pointIds]; - const selectedSegmentIds = [...this.selection.segmentIds]; - - const content = resolveClipboardContent( - glyph, - new Set(selectedPointIds), - new Set(selectedSegmentIds), - ); + const content = this.#selectedClipboardContent(); if (!content || content.contours.length === 0) return []; - const result = this.#bridge.pasteContours(content.contours, 0, 0); - return result.success ? result.createdPointIds : []; + const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); + this.#commandHistory.execute(command); + return command.createdPointIds; } public getSegmentById(segmentId: SegmentId) { @@ -1787,7 +1748,7 @@ export class Editor { * detect mid-contour clicks for splitting or joining. */ private getMiddlePointAt(coords: Coordinates): MiddlePointHit | null { - const glyph = this.#$glyph.value; + const glyph = this.#$editingGlyph.value; if (!glyph) return null; const activeContourId = this.getActiveContourId(); @@ -1795,7 +1756,7 @@ export class Editor { for (const contour of glyph.contours) { if (contour.id === activeContourId || contour.closed) continue; - if (!Contours.hasInteriorPoints(contour)) continue; + if (!contour.hasInteriorPoints) continue; for (let i = 1; i < contour.points.length - 1; i++) { const point = contour.points[i]; diff --git a/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts new file mode 100644 index 00000000..6d53189d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createBridge } from "@shift/bridge"; +import type { PointId } from "@shift/types"; +import { signal } from "@/lib/signals/signal"; +import { CommandHistory } from "@/lib/commands/core/CommandHistory"; +import { Font } from "@/lib/model/Font"; +import type { GlyphSource } from "@/lib/model/Glyph"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import { SourceEditDraft } from "./SourceEditDraft"; + +function editableSource(): GlyphSource { + const bridge = createBridge(); + const font = new Font(bridge); + font.load(MUTATORSANS_DESIGNSPACE); + + const handle = { name: "A", unicode: 65 }; + const source = font.sourceAt(font.defaultLocation()); + if (!source) throw new Error("Expected editable source"); + bridge.startEditSession(handle, source.id); + + const glyphSource = font.glyphSource(handle, source); + if (!glyphSource) throw new Error("Expected editable glyph source"); + + return glyphSource; +} + +function pointPosition(source: GlyphSource, pointId: PointId): { x: number; y: number } { + const point = source.point(pointId); + if (!point) throw new Error("Expected point"); + + return { x: point.x, y: point.y }; +} + +describe("SourceEditDraft", () => { + it("previews, commits, and undoes a source edit through the real glyph source", () => { + const source = editableSource(); + const point = source.allPoints[0]; + if (!point) throw new Error("Expected point"); + + const start = pointPosition(source, point.id); + const history = new CommandHistory(signal(source)); + const draft = new SourceEditDraft(source, history, { points: [point.id] }); + + draft.previewTranslate({ x: 25, y: -10 }); + expect(pointPosition(source, point.id)).toEqual({ x: start.x + 25, y: start.y - 10 }); + + draft.commit("Move Point"); + expect(pointPosition(source, point.id)).toEqual({ x: start.x + 25, y: start.y - 10 }); + + history.undo(); + expect(pointPosition(source, point.id)).toEqual(start); + }); + + it("discards rule-driven previews that include points outside the initial subject", () => { + const source = editableSource(); + const [first, second] = source.allPoints; + if (!first || !second) throw new Error("Expected points"); + + const firstStart = pointPosition(source, first.id); + const secondStart = pointPosition(source, second.id); + const history = new CommandHistory(signal(source)); + const draft = new SourceEditDraft(source, history, { points: [first.id] }); + + draft.previewPositions([ + { kind: "point", id: first.id, x: firstStart.x + 10, y: firstStart.y }, + { kind: "point", id: second.id, x: secondStart.x + 20, y: secondStart.y }, + ]); + + expect(pointPosition(source, first.id).x).toBe(firstStart.x + 10); + expect(pointPosition(source, second.id).x).toBe(secondStart.x + 20); + + draft.discard(); + + expect(pointPosition(source, first.id)).toEqual(firstStart); + expect(pointPosition(source, second.id)).toEqual(secondStart); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.ts b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.ts new file mode 100644 index 00000000..0d7ab7d7 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/editor/SourceEditDraft.ts @@ -0,0 +1,84 @@ +import type { Point2D } from "@shift/geo"; +import type { GlyphGeometry, GlyphSource, SourcePositions } from "@/lib/model/Glyph"; +import { SourcePositionList, type SourcePositionSubject } from "@/lib/model/SourcePositionList"; +import { SetSourcePositionsCommand } from "@/lib/commands/primitives/SetSourcePositionsCommand"; +import type { CommandHistory } from "@/lib/commands/core/CommandHistory"; + +export type SourceEditSubject = SourcePositionSubject; + +export class SourceEditDraft { + readonly glyphSource: GlyphSource; + readonly subject: SourceEditSubject; + readonly baseGeometry: GlyphGeometry; + + readonly #commandHistory: CommandHistory; + #base: SourcePositionList; + #preview: SourcePositionList | null = null; + #closed = false; + + constructor( + glyphSource: GlyphSource, + commandHistory: CommandHistory, + subject: SourceEditSubject, + ) { + this.glyphSource = glyphSource; + this.baseGeometry = glyphSource.geometry; + + this.subject = { + points: subject.points ? [...subject.points] : [], + anchors: subject.anchors ? [...subject.anchors] : [], + }; + + this.#commandHistory = commandHistory; + this.#base = SourcePositionList.fromSubject(this.baseGeometry, this.subject); + } + + get basePositions(): SourcePositions { + return this.#base.positions; + } + + previewPositions(positions: SourcePositions): void { + if (this.#closed) return; + + this.#base = this.#base.includeFromGeometry(this.baseGeometry, positions); + this.#preview = SourcePositionList.fromPositions(positions); + this.glyphSource.previewPositions(this.#preview.positions); + } + + previewTranslate(delta: Point2D): void { + this.preview(this.#base.translate(delta)); + } + + previewRotate(angle: number, origin: Point2D): void { + this.preview(this.#base.rotate(angle, origin)); + } + + previewScale(sx: number, sy: number, origin: Point2D): void { + this.preview(this.#base.scale(sx, sy, origin)); + } + + preview(positions: SourcePositionList): void { + this.previewPositions(positions.positions); + } + + commit(label: string): void { + if (this.#closed) return; + this.#closed = true; + + if (!this.#preview || this.#preview.positions.length === 0) return; + + this.glyphSource.setPositions(this.#preview.positions); + this.#commandHistory.record( + new SetSourcePositionsCommand(label, this.#base.positions, this.#preview.positions), + ); + } + + discard(): void { + if (this.#closed) return; + this.#closed = true; + + if (this.#base.positions.length > 0) { + this.glyphSource.previewPositions(this.#base.positions); + } + } +} diff --git a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md index 5b909f40..75178d4c 100644 --- a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md @@ -1,10 +1,10 @@ # Editor -Central orchestrator for the canvas-based glyph editing surface, wiring viewport transforms, selection, rendering, snapping, hit testing, and tool management into a single facade. +Central orchestrator for the canvas-based glyph editing surface, wiring viewport transforms, selection, rendering, hit testing, and tool management into a single facade. ## Architecture Invariants -**Architecture Invariant:** `Editor` is a facade -- it delegates to managers (`ViewportManager`, `HoverManager`, `EdgePanManager`, `SnapManager`) and a renderer (`Viewport`). Tools receive `Editor` directly but must not reach into private managers. +**Architecture Invariant:** `Editor` is a facade -- it delegates to managers (`ViewportManager`, `HoverManager`, `EdgePanManager`) and a renderer (`Viewport`). Tools receive `Editor` directly but must not reach into private managers. **Architecture Invariant:** Three coordinate spaces flow through every interaction: `screen` (canvas pixels, Y-down), `scene` (UPM with viewport transform applied), and `glyphLocal` (scene minus draw offset). All three are bundled in the `Coordinates` type; build one via `Editor.fromScreen()` / `fromScene()` / `fromGlyphLocal()` -- never compute one space from another manually. @@ -16,7 +16,7 @@ Central orchestrator for the canvas-based glyph editing surface, wiring viewport **Architecture Invariant:** `FrameHandler` deduplicates `requestAnimationFrame` calls per canvas layer. Multiple signal changes within a single frame coalesce into one render. Never call rendering functions directly; always go through `requestRedraw()` / `requestSceneRedraw()` / `requestOverlayRedraw()`. -**Architecture Invariant:** `GlyphDraft` is the only way to perform continuous point manipulations (drags). Call `createDraft()` at drag start, `setPositions()` on each move, and either `finish(label)` or `discard()` at drag end. Draft internally records a single undo command from the base snapshot. Calling `finish` twice is a no-op; forgetting to call `finish`/`discard` leaks the draft. +**Architecture Invariant:** `SourceEditDraft` is the only way to perform continuous source previews (drags). Call `beginSourceEditDraft()` at drag start, `preview*()` on each move, and either `commit(label)` or `discard()` at drag end. Draft internally records a single undo command from frozen base positions. Calling `commit` twice is a no-op; forgetting to call `commit`/`discard` leaks the preview state. **Architecture Invariant:** Lifecycle events (`EventEmitter`) are for one-shot imperative actions (`fontLoaded`, `fontSaved`, `destroying`). Continuous state changes use signals. Do not mix the two patterns. @@ -37,7 +37,6 @@ editor/ ViewportManager.ts -- UPM<->screen affine matrices, zoom, pan HoverManager.ts -- Hover state (point/anchor/segment/bounding box) EdgePanManager.ts -- Auto-pan when dragging near canvas edges - SnapManager.ts -- Creates DragSnapSession / RotateSnapSession rendering/ Viewport.ts -- Canvas layer orchestration and RAF scheduling Canvas.ts -- 2D drawing API wrapping CanvasRenderingContext2D @@ -49,19 +48,15 @@ editor/ visibleSceneBounds.ts -- Frustum culling for off-screen elements gpu/ -- WebGL handle shaders and instance packing indicators/ -- Guides, BoundingBox, ControlLines, Segments, - SnapLines, Anchors, DebugOverlays, handleDrawing + Anchors, DebugOverlays, handleDrawing hit/ boundingBox.ts -- hitTestBoundingBox, getHandlePositions composite.ts -- Composite glyph hit testing - snapping/ - SnapPipelineRunner.ts -- Ordered step pipeline for snap resolution - steps.ts -- pointToPoint, metrics, angle snap steps - types.ts -- DragSnapSession, RotateSnapSession, SnapIndicator ``` ## Key Types -- **`Editor`** -- Facade class (~1750 lines). Owns `Selection`, `ViewportManager`, `HoverManager`, `SnapManager`, `EdgePanManager`, `Viewport` (renderer), `ToolManager`, `CommandHistory`, `Clipboard`, `EventEmitter`. Passed directly to tools. +- **`Editor`** -- Facade class (~1750 lines). Owns `Selection`, `ViewportManager`, `HoverManager`, `EdgePanManager`, `Viewport` (renderer), `ToolManager`, `CommandHistory`, `Clipboard`, `EventEmitter`. Passed directly to tools. - **`ViewportManager`** -- Owns zoom/pan/UPM signals, computed affine matrices (`Mat`), and all coordinate projection methods (`projectScreenToScene`, `projectSceneToScreen`, `screenToUpmDistance`). - **`Viewport`** -- Manages four stacked canvas layers (background, scene, handles/WebGL, overlay) and their `FrameHandler` instances. Calls back into `Editor.renderToolBackground()` / `renderToolScene()` / `renderOverlay()`. - **`Canvas`** -- Thin wrapper around `CanvasRenderingContext2D` with `pxToUpm()` conversion and themed drawing primitives. Carries `ViewportTransform` and `Theme`. @@ -69,15 +64,13 @@ editor/ - **`Selection`** -- Unified selection state for points, anchors, and segments. Computed `DerivedSelection` tracks which contours are (fully) selected. Exposes both raw signals (`$pointIds`) and unwrapped getters. - **`Selectable`** -- Discriminated union: `{ kind: "point" | "anchor" | "segment", id }`. - **`Coordinates`** -- Triple of `{ screen, scene, glyphLocal }` for a single position. Built via `Editor.fromScreen()` etc. -- **`GlyphDraft`** -- Transactional interface for continuous point manipulation: `setPositions()` during drag, `finish(label)` or `discard()` at end. +- **`SourceEditDraft`** -- Transactional interface for continuous source manipulation: `previewPositions()` / `previewTranslate()` / `previewRotate()` / `previewScale()` during drag, `commit(label)` or `discard()` at end. - **`HoverManager`** -- Tracks which entity (point/anchor/segment/bounding box handle) is hovered. `applyHoverResult()` sets the right signal and clears others. `getPointVisualState()` / `getSegmentVisualState()` return `VisualState` (`"idle" | "hovered" | "selected"`). -- **`SnapManager`** -- Stateless factory for `DragSnapSession` and `RotateSnapSession`. Sessions freeze snap targets at creation, then run `SnapPipelineRunner` on each mouse move. -- **`SnapPipelineRunner`** -- Executes ordered snap steps. Point pipeline: point-to-point wins immediately, otherwise closest result wins. Rotate pipeline: first match wins. - **`Handles`** -- GPU-first handle rendering. Packs instances via `packHandleInstances()`, falls back to `drawCpu()`. - **`FrameHandler`** -- Deduplicates `requestAnimationFrame` per render target. Only the latest callback fires. - **`EdgePanManager`** -- During drags, auto-pans when the cursor is within 50px of canvas edges, using velocity proportional to distance from edge. - **`EventEmitter`** -- Typed emitter for `LifecycleEventMap` (`fontLoaded`, `fontSaved`, `destroying`). -- **`Theme`** -- Full visual config: colors, sizes, line widths for guides, handles, selection, snapping, bounding box, debug overlays, text run. +- **`Theme`** -- Full visual config: colors, sizes, line widths for guides, handles, selection, bounding box, debug overlays, text run. ## How it works @@ -106,9 +99,9 @@ For direct glyph opens, `Editor.openGlyph()` creates a one-cell implicit editor | background | Canvas 2D | Guides, tool backgrounds | `#staticEffect` | | scene | Canvas 2D | Glyph outline, segments, handles (CPU), anchors, tool scene | `#staticEffect` | | handles | WebGL (regl) | GPU-rendered point handles | `#staticEffect` (via scene render) | -| overlay | Canvas 2D | Bounding box handles, snap lines, tool overlays | `#overlayEffect` | +| overlay | Canvas 2D | Bounding box handles, tool overlays | `#overlayEffect` | -Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies the affine transform and draw offset). Overlay has a two-pass render: screen-space (bounding box handles) then UPM-space (snap lines, tool overlay). +Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies the affine transform and draw offset). Overlay has a two-pass render: screen-space (bounding box handles) then UPM-space (tool overlay). ### Rendering pipeline @@ -126,13 +119,9 @@ Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies `ViewportManager.zoomToPoint()` records UPM at cursor before zoom, applies new zoom, re-projects, and adjusts pan to compensate for drift. Because matrices are computed signals, the second projection automatically uses the updated zoom. -### Snapping - -`SnapManager.createDragSession()` freezes snap targets (other points, font metrics) at drag start. Each mouse move calls `session.snap(cursor, modifiers)` which runs `SnapPipelineRunner` through ordered steps (point-to-point, metrics, angle). Point-to-point short-circuits; otherwise closest wins. The session returns a `SnapIndicator` that Editor renders on the overlay layer. - ### Draft pattern (continuous manipulation) -`Editor.createDraft()` captures a base `GlyphSnapshot`. During drag, `draft.setPositions(updates)` applies position deltas directly to the glyph model. On finish, it syncs with the bridge and records a single `SetNodePositionsCommand`. On discard, it restores the base snapshot. +`Editor.beginSourceEditDraft(subject)` captures base point/anchor positions from the active `GlyphSource`. During drag, `draft.preview*()` applies positions to the reactive glyph source only. On commit, it syncs through `GlyphSource.setPositions()` and records a single `SetSourcePositionsCommand`. On discard, it restores the frozen base positions as a preview. ### Hit testing @@ -154,12 +143,6 @@ Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies 3. Call `#myIndicator.draw(canvas, ...)` from `renderToolScene()` or `renderOverlay()`. 4. If it depends on new state, read that state in the appropriate effect. -### Add a new snap step - -1. Create a function returning `PointSnapStep` in `snapping/steps.ts`. -2. Add it to the `steps` array in `SnapManager.createDragSession()`. -3. Pipeline ordering matters: point-to-point short-circuits, so place it correctly. - ### Add a new selectable entity kind 1. Extend the `Selectable` discriminated union. @@ -171,8 +154,7 @@ Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies - **Forgetting to read a signal in an effect** -- The canvas will not redraw when that state changes. Each effect must explicitly read `.value` of every signal it depends on. - **Caching `ViewportTransform` across frames** -- `getViewportTransform()` returns a snapshot object. It is correct for one frame but stale after zoom/pan changes. Rendering code gets a fresh one via `Viewport.#getCanvas()`. -- **Snap indicator cleanup** -- Tools must call `setSnapIndicator(null)` when a drag ends or the tool deactivates, or a stale indicator remains on screen. -- **Draft lifecycle** -- A `GlyphDraft` must be finished or discarded. Calling `finish()` twice is safe (no-op), but forgetting both leaks the intermediate state. +- **Draft lifecycle** -- A `SourceEditDraft` must be committed or discarded. Calling `commit()` twice is safe (no-op), but forgetting both leaks the preview state. - **Hover mutual exclusion** -- `HoverManager.setHoveredPoint()` clears hovered anchor and segment. `setHoveredAnchor()` clears point and segment. This prevents multiple hover highlights. - **GPU handle fallback** -- `Handles.draw()` returns `false` if GPU rendering failed or was disabled. The caller must check the return value and fall back to `drawCpu()`. - **Three-space coordinates** -- Never convert between screen/scene/glyphLocal manually. Always use `Editor.fromScreen()` / `fromScene()` / `fromGlyphLocal()` which guarantee all three are consistent. @@ -181,17 +163,17 @@ Background and scene are drawn in UPM space (`Viewport.#beginUpmSpace()` applies - `npx vitest run apps/desktop/src/renderer/src/lib/editor/` -- unit tests for managers, hit testing, sidebearings, lifecycle, drafts. - `npx vitest run --testPathPattern="draft"` -- draft-specific tests. -- `npx vitest run --testPathPattern="EdgePanManager|HoverManager|ViewportManager|SnapManager"` -- manager unit tests. -- Manual: open a font, zoom/pan, select points, drag with snapping, toggle preview mode, verify GPU/CPU handle rendering toggle. +- `npx vitest run --testPathPattern="EdgePanManager|HoverManager|ViewportManager"` -- manager unit tests. +- Manual: open a font, zoom/pan, select points, drag, toggle preview mode, verify GPU/CPU handle rendering toggle. ## Related -- `NativeBridge` -- State source; `Editor.#bridge` provides `$glyph`, snapshot sync, boolean ops. +- `NativeBridge` -- State source; `Editor.#bridge` provides source-aware glyph state and edit-session mutations. - `CommandHistory` -- Undo/redo stack; `Editor.#commandHistory` records all mutations. - `ToolManager` -- Tool lifecycle and dispatch; `Editor.#toolManager`. Tools receive `Editor` to access all subsystems. - `Clipboard` -- Copy/cut/paste via `Editor.#clipboard`. - `Font` -- Font model access; `Editor.font` for metrics, glyph names, composites. - `Selection` -- `Editor.selection` (public). Point/anchor/segment selection with computed contour queries. - `Mat` (from `@shift/geo`) -- 2D affine matrix used by `ViewportManager` for coordinate transforms. -- `Segment` (from `../geo/Segment`) -- Segment iteration and hit testing used by `Editor.getSegmentAt()`. +- `Segment` (from `@shift/glyph-state`) -- Segment iteration and hit testing used by `Editor.getSegmentAt()`. - `ReglHandleContext` (from `graphics/backends`) -- WebGL context for GPU handle rendering. diff --git a/apps/desktop/src/renderer/src/lib/editor/draft.test.ts b/apps/desktop/src/renderer/src/lib/editor/draft.test.ts deleted file mode 100644 index 3c79859a..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/draft.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { TestEditor } from "@/testing/TestEditor"; -import { Glyphs } from "@shift/font"; -import type { PointId } from "@shift/types"; - -let editor: TestEditor; - -beforeEach(() => { - editor = new TestEditor(); - editor.startSession(); - editor.bridge.addContour(); -}); - -function addPoint(x: number, y: number): PointId { - return editor.bridge.addPoint({ x, y, pointType: "onCurve", smooth: false }); -} - -describe("GlyphDraft", () => { - it("syncs position updates to Rust on finish", () => { - const p1 = addPoint(100, 200); - const p2 = addPoint(300, 400); - - const draft = editor.createDraft(); - - draft.setPositions([ - { node: { kind: "point", id: p1 }, x: 150, y: 250 }, - { node: { kind: "point", id: p2 }, x: 350, y: 450 }, - ]); - - draft.finish("Move Points"); - - expect(editor.getPointPosition(p1)).toEqual({ x: 150, y: 250 }); - expect(editor.getPointPosition(p2)).toEqual({ x: 350, y: 450 }); - }); - - it("restores positions on undo after finish", () => { - const p1 = addPoint(100, 200); - - const draft = editor.createDraft(); - draft.setPositions([{ node: { kind: "point", id: p1 }, x: 999, y: 888 }]); - draft.finish("Move Point"); - - editor.undo(); - - expect(editor.getPointPosition(p1)).toEqual({ x: 100, y: 200 }); - }); - - it("restores positions on redo after undo", () => { - const p1 = addPoint(100, 200); - - const draft = editor.createDraft(); - draft.setPositions([{ node: { kind: "point", id: p1 }, x: 999, y: 888 }]); - draft.finish("Move Point"); - - editor.undo(); - editor.redo(); - - expect(editor.getPointPosition(p1)).toEqual({ x: 999, y: 888 }); - }); - - it("does not modify Rust on discard", () => { - const p1 = addPoint(100, 200); - - const draft = editor.createDraft(); - draft.setPositions([{ node: { kind: "point", id: p1 }, x: 999, y: 888 }]); - draft.discard(); - - expect(editor.getPointPosition(p1)).toEqual({ x: 100, y: 200 }); - }); - - it("JS model matches Rust after finish", () => { - const p1 = addPoint(100, 200); - - const draft = editor.createDraft(); - draft.setPositions([{ node: { kind: "point", id: p1 }, x: 50, y: 75 }]); - draft.finish("Move"); - - const jsPosition = editor.getPointPosition(p1); - const rustPoint = Glyphs.findPoint(editor.bridge.getSnapshot(), p1)!.point; - - expect(jsPosition).toEqual({ x: rustPoint.x, y: rustPoint.y }); - expect(jsPosition).toEqual({ x: 50, y: 75 }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.test.ts b/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.test.ts index 1e457cdf..42d47d75 100644 --- a/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.test.ts @@ -5,7 +5,7 @@ import { isBoundingBoxVisibleAtZoom, BOUNDING_BOX_MIN_VISIBLE_ZOOM, } from "./boundingBox"; -import type { Rect2D } from "@shift/types"; +import type { Rect2D } from "@shift/geo"; const createRect = (x: number, y: number, width: number, height: number): Rect2D => ({ x, diff --git a/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.ts b/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.ts index c0d540c0..9f876d86 100644 --- a/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.ts +++ b/apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.ts @@ -1,4 +1,4 @@ -import type { Point2D, Rect2D } from "@shift/types"; +import type { Point2D, Rect2D } from "@shift/geo"; import { Vec2 } from "@shift/geo"; import type { BoundingBoxHitResult, CornerHandle } from "@/types/boundingBox"; import type { BoundingRectEdge } from "@/lib/tools/select/cursor"; diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.test.ts b/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.test.ts index 65cca626..df2b56fd 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { TestEditor } from "@/testing/TestEditor"; -import type { Rect2D } from "@shift/types"; +import type { Rect2D } from "@shift/geo"; const canvasBounds: Rect2D = { x: 0, diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.ts b/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.ts index 1eb0331e..ce648d34 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/EdgePanManager.ts @@ -1,4 +1,4 @@ -import type { Point2D, Rect2D } from "@shift/types"; +import type { Point2D, Rect2D } from "@shift/geo"; import { Vec2 } from "@shift/geo"; interface EdgePanConfig { diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.ts b/apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.ts index 12621a87..f49f3e77 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.ts @@ -1,4 +1,4 @@ -import { signal, type WritableSignal, type Signal } from "../../reactive/signal"; +import { signal, type WritableSignal, type Signal } from "../../signals/signal"; import type { PointId, AnchorId } from "@shift/types"; import { asPointId } from "@shift/types"; import type { SegmentId, SegmentIndicator } from "@/types/indicator"; diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts b/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts deleted file mode 100644 index dcbbf0d0..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { createBridge } from "@/testing"; -import type { NativeBridge } from "@/bridge"; -import { SnapManager } from "./SnapManager"; - -const metrics = { - unitsPerEm: 1000, - ascender: 800, - descender: -200, - capHeight: 700, - xHeight: 500, - lineGap: null, - italicAngle: null, - underlinePosition: null, - underlineThickness: null, -}; - -const prefs = { - enabled: true, - angle: true, - metrics: true, - pointToPoint: true, - pointRadiusPx: 8, - angleIncrementDeg: 45, -}; - -let bridge: NativeBridge; - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A" }); - const glyph = bridge.$glyph.peek()!; - glyph.addContour(); - glyph.addPointToContour(glyph.activeContourId!, { - x: 0, - y: 0, - pointType: "onCurve", - smooth: false, - }); - glyph.addPointToContour(glyph.activeContourId!, { - x: 100, - y: 100, - pointType: "onCurve", - smooth: false, - }); -}); - -function createManager() { - return new SnapManager( - bridge.$glyph, - () => metrics, - () => prefs, - (px) => px, - ); -} - -describe("SnapManager", () => { - it("snaps point to nearest point target", () => { - const glyph = bridge.$glyph.peek()!; - const points = glyph.allPoints; - const p1 = points[0]!; - - const manager = createManager(); - const session = manager.createDragSession({ - anchorPointId: p1.id, - dragStart: { x: 0, y: 0 }, - excludedPointIds: [p1.id], - }); - - const result = session.snap({ x: 98, y: 102 }, { shiftKey: false }); - expect(result.point.x).toBe(100); - expect(result.point.y).toBe(100); - }); - - it("returns unsnapped point when out of range", () => { - const glyph = bridge.$glyph.peek()!; - const points = glyph.allPoints; - const p1 = points[0]!; - - const manager = createManager(); - const session = manager.createDragSession({ - anchorPointId: p1.id, - dragStart: { x: 0, y: 0 }, - excludedPointIds: [p1.id], - }); - - const result = session.snap({ x: 50, y: 50 }, { shiftKey: false }); - expect(result.point.x).toBe(50); - expect(result.point.y).toBe(50); - }); - - it("creates rotate session that snaps to angle increment", () => { - const manager = createManager(); - const session = manager.createRotateSession(); - const result = session.snap(0.78, { shiftKey: true }); - expect(result.delta).toBeCloseTo(Math.PI / 4, 5); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.ts b/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.ts deleted file mode 100644 index 0212bc62..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/managers/SnapManager.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Vec2 } from "@shift/geo"; -import { Contours } from "@shift/font"; -import { Validate } from "@shift/validation"; -import type { Point2D, PointId, FontMetrics } from "@shift/types"; -import type { Glyph } from "@/lib/model/Glyph"; -import type { Signal } from "@/lib/reactive/signal"; -import type { SnapPreferences } from "@/types/editor"; -import type { - DragSnapSession, - DragSnapSessionConfig, - PointSnap, - PointSnapStep, - RotateSnapSession, - RotateSnap, - RotateSnapStep, - SnapContext, - SnappableObject, - SnappableQuery, -} from "../snapping/types"; -import { SnapPipelineRunner } from "../snapping/SnapPipelineRunner"; -import { - createPointToPointStep, - createMetricsStep, - createAngleStep, - createRotateAngleStep, - collectMetricSources, -} from "../snapping/steps"; - -/** - * Creates snap sessions for drag and rotate operations. - * - * Stateless between sessions. Each session freezes snap targets at creation - * time so the pipeline does not recompute them on every mouse move. - */ -export class SnapManager { - readonly #$glyph: Signal; - readonly #getMetrics: () => FontMetrics; - readonly #getPreferences: () => SnapPreferences; - readonly #screenToUpm: (px: number) => number; - readonly #runner: SnapPipelineRunner; - - constructor( - $glyph: Signal, - getMetrics: () => FontMetrics, - getPreferences: () => SnapPreferences, - screenToUpm: (px: number) => number, - ) { - this.#$glyph = $glyph; - this.#getMetrics = getMetrics; - this.#getPreferences = getPreferences; - this.#screenToUpm = screenToUpm; - this.#runner = new SnapPipelineRunner(); - } - - createDragSession(config: DragSnapSessionConfig): DragSnapSession { - const context: SnapContext = { previousSnappedAngle: null }; - const steps: PointSnapStep[] = [ - createPointToPointStep(), - createMetricsStep(), - createAngleStep(), - ]; - const query: SnappableQuery = { - include: ["points", "metrics"], - }; - - if (config.excludedPointIds) { - query.excludedPointIds = config.excludedPointIds; - } - - const sources = this.#getSnappableObjects(query); - - const glyph = this.#$glyph.peek(); - const anchorPosition = this.#getAnchorPosition(glyph, config.anchorPointId, config.dragStart); - const reference = this.#resolveSnapReference(glyph, config.anchorPointId, config.dragStart); - - return { - getAnchorPosition: () => anchorPosition, - snap: (cursorPoint, modifiers): PointSnap => { - const pointPosition = Vec2.add(anchorPosition, Vec2.sub(cursorPoint, config.dragStart)); - const prefs = this.#getPreferences(); - - return this.#runner.runPointPipeline(steps, { - point: pointPosition, - reference, - modifiers, - context, - sources, - preferences: prefs, - radius: this.#screenToUpm(prefs.pointRadiusPx), - increment: (prefs.angleIncrementDeg * Math.PI) / 180, - }); - }, - clear: () => { - context.previousSnappedAngle = null; - }, - }; - } - - createRotateSession(): RotateSnapSession { - const context: SnapContext = { previousSnappedAngle: null }; - const steps: RotateSnapStep[] = [createRotateAngleStep()]; - - return { - snap: (delta, modifiers): RotateSnap => { - const prefs = this.#getPreferences(); - - return this.#runner.runRotatePipeline(steps, { - delta, - modifiers, - context, - preferences: prefs, - increment: (prefs.angleIncrementDeg * Math.PI) / 180, - }); - }, - clear: () => { - context.previousSnappedAngle = null; - }, - }; - } - - #getSnappableObjects(query: SnappableQuery): SnappableObject[] { - const result: SnappableObject[] = []; - - if (query.include.includes("points")) { - const excluded = new Set(query.excludedPointIds ?? []); - const glyph = this.#$glyph.peek(); - if (glyph) { - for (const point of glyph.allPoints) { - if (excluded.has(point.id)) continue; - result.push({ - kind: "pointTarget", - id: point.id, - point: { x: point.x, y: point.y }, - }); - } - } - } - - if (query.include.includes("metrics")) { - result.push(...collectMetricSources(this.#getMetrics())); - } - - return result; - } - - #getAnchorPosition(snapshot: Glyph | null, pointId: PointId, fallback: Point2D): Point2D { - if (!snapshot) return fallback; - const result = snapshot.point(pointId); - if (result) return { x: result.point.x, y: result.point.y }; - return fallback; - } - - #resolveSnapReference(snapshot: Glyph | null, pointId: PointId, fallback: Point2D): Point2D { - if (!snapshot) return fallback; - - const found = snapshot.point(pointId); - if (found) { - const { point, contour, index: idx } = found; - if (Validate.isOnCurve(point)) { - return fallback; - } - - const { prev, next } = Contours.neighbors(contour, idx); - if (!prev || !next) return fallback; - - if (Validate.isOffCurve(next) && Validate.isOnCurve(prev)) { - return { x: prev.x, y: prev.y }; - } - if (Validate.isOnCurve(next)) { - return { x: next.x, y: next.y }; - } - if (Validate.isOnCurve(prev)) { - return { x: prev.x, y: prev.y }; - } - - return fallback; - } - - return fallback; - } -} diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.test.ts b/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.test.ts index f80d32a0..a9d132ee 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { ViewportManager } from "./ViewportManager"; -import type { Rect2D } from "@shift/types"; +import type { Rect2D } from "@shift/geo"; describe("ViewportManager", () => { let viewport: ViewportManager; diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.ts b/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.ts index d356d58d..1a2a2413 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.ts @@ -1,13 +1,12 @@ import { clamp } from "@/lib/utils/utils"; -import type { Point2D, Rect2D } from "@shift/types"; -import { Mat } from "@shift/geo"; +import { Mat, type Point2D, type Rect2D } from "@shift/geo"; import { signal, computed, type WritableSignal, type Signal, type ComputedSignal, -} from "@/lib/reactive/signal"; +} from "@/lib/signals/signal"; import { SCREEN_HIT_RADIUS } from "../rendering/constants"; /** Lower bound for zoom level. Prevents the glyph from becoming invisible. */ diff --git a/apps/desktop/src/renderer/src/lib/editor/managers/index.ts b/apps/desktop/src/renderer/src/lib/editor/managers/index.ts index 6a375dad..7e8e19af 100644 --- a/apps/desktop/src/renderer/src/lib/editor/managers/index.ts +++ b/apps/desktop/src/renderer/src/lib/editor/managers/index.ts @@ -1,4 +1,3 @@ export { HoverManager } from "./HoverManager"; export { ViewportManager } from "./ViewportManager"; export { EdgePanManager } from "./EdgePanManager"; -export { SnapManager } from "./SnapManager"; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts index 75c31098..069be171 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Canvas.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Theme } from "./Theme"; import { DEFAULT_THEME } from "./Theme"; import type { ViewportTransform } from "./Viewport"; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/FpsMonitor.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/FpsMonitor.ts index e4852125..aed4a16c 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/FpsMonitor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/FpsMonitor.ts @@ -1,4 +1,4 @@ -import { signal, type Signal, type WritableSignal } from "@/lib/reactive/signal"; +import { signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; const UPDATE_INTERVAL_MS = 250; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts index a7c4a96c..2653d456 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts @@ -1,11 +1,11 @@ -import type { Point2D, PointId } from "@shift/types"; +import type { PointId } from "@shift/types"; import type { Glyph } from "@/lib/model/Glyph"; import type { HandleState } from "@/types/graphics"; import type { Canvas } from "./Canvas"; import type { ViewportTransform } from "./Viewport"; import { Gpu } from "@/lib/graphics/backends/Gpu"; import { packHandleInstances } from "./gpu/classifyHandles"; -import { Vec2 } from "@shift/geo"; +import { Point2D, Vec2 } from "@shift/geo"; import { Validate } from "@shift/validation"; import { drawHandle, @@ -13,6 +13,7 @@ import { drawHandleDirection, drawHandleLast, } from "./indicators/handleDrawing"; +import { Contour } from "@shift/glyph-state"; export interface HandleStates { getHandleState: (pointId: PointId) => HandleState; @@ -31,14 +32,14 @@ export class Handles { } draw( - glyph: Glyph, + contours: readonly Contour[], states: HandleStates, viewport: ViewportTransform, drawOffset: Point2D, gpuEnabled: boolean, ): boolean { if (gpuEnabled && this.#gpu?.isAvailable()) { - return this.#drawGpu(glyph, states, viewport, drawOffset); + return this.#drawGpu(contours, states, viewport, drawOffset); } return false; } @@ -91,7 +92,7 @@ export class Handles { } #drawGpu( - glyph: Glyph, + contours: readonly Contour[], states: HandleStates, viewport: ViewportTransform, drawOffset: Point2D, @@ -99,7 +100,7 @@ export class Handles { if (!this.#gpu?.isAvailable()) return false; const { packedInstances, instanceCount } = packHandleInstances( - glyph, + contours, (id) => states.getHandleState(id), viewport, drawOffset, diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts index 648a9f10..8bfa305b 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts @@ -7,8 +7,8 @@ * and re-draws when they change. * * Coordinate space note: the canvas inside `renderToolScene` has been - * translated by `editor.drawOffset` (Viewport.ts:174) so that `glyph.draw` - * for the active glyph lands at world `drawOffset`. The TextLayout, by + * translated by `editor.drawOffset` (Viewport.ts:174) so the active glyph + * outline lands at world `drawOffset`. The TextLayout, by * contrast, holds glyph positions in *world* (scene) UPM space — same space * that `event.point` arrives in via `coords.scene`. * @@ -25,13 +25,16 @@ import type { Canvas } from "./Canvas"; import type { Font } from "@/lib/model/Font"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { Signal } from "@/lib/signals/signal"; +import type { AxisLocation } from "@/types/variation"; export class Text { draw( canvas: Canvas, run: TextRun, font: Font, + designLocation: Signal, drawOffset: Point2D, focusedGlyph: FocusedGlyph | null, ): void { @@ -39,6 +42,7 @@ export class Text { if (!layout) return; const theme = canvas.theme.textRun; + const source = font.sourceAtOrDefault(designLocation.value); canvas.save(); // Reverse the drawOffset translate so we draw in world UPM space. @@ -63,21 +67,20 @@ export class Text { continue; } - // GlyphView.$path is a cached Path2D — only re-built when the - // variation location moves (or the glyph's geometry changes). - // The Editor's staticEffect already tracks $variationLocation - // and requests a scene redraw, so peek() is correct here. - const view = font.glyph(g.glyphName); - if (view) { - const path = view.$path.peek(); - canvas.save(); - canvas.translate(runBase + g.origin.x + g.xOffset, line.y + g.origin.y + g.yOffset); - canvas.fillPath(path, canvas.theme.glyph.fill); - if (g.cluster === hoveredCluster) { - canvas.strokePath(path, theme.hoverOutline, theme.hoverOutlineWidthPx); - } - canvas.restore(); + if (!source) continue; + const glyph = font.glyph({ name: g.glyphName }); + if (!glyph) continue; + + const path = glyph.outline(designLocation).path; + + canvas.save(); + canvas.translate(runBase + g.origin.x + g.xOffset, line.y + g.origin.y + g.yOffset); + canvas.fillPath(path, canvas.theme.glyph.fill); + + if (g.cluster === hoveredCluster) { + canvas.strokePath(path, theme.hoverOutline, theme.hoverOutlineWidthPx); } + canvas.restore(); } runBase += r.advance; } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Theme.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Theme.ts index 5865bbef..238f559f 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Theme.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Theme.ts @@ -36,7 +36,6 @@ export interface Theme { first: HandleStateStyles; last: HandleStateStyles; }; - snap: { color: string; widthPx: number; crossSizePx: number }; segment: { hoverColor: string; selectedColor: string; @@ -172,7 +171,6 @@ export const DEFAULT_THEME: Theme = { selected: { fill: "#ffffff", stroke: "#0C92F4", size: 14, lineWidth: 2 }, }, }, - snap: { color: "#ff3b30", widthPx: 1, crossSizePx: 2 }, segment: { hoverColor: "#1886D7", selectedColor: "#1886D7", diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts index b1a42a99..7068fb98 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Theme } from "./Theme"; import { DEFAULT_THEME } from "./Theme"; import { Canvas } from "./Canvas"; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/classifyHandles.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/classifyHandles.ts index 606bfeea..0d4d58be 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/classifyHandles.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/classifyHandles.ts @@ -1,24 +1,24 @@ -import type { Point2D, PointId, PointType } from "@shift/types"; -import type { Glyph } from "@/lib/model/Glyph"; +import type { PointId, PointType } from "@shift/types"; import type { HandleState } from "@/types/graphics"; -import { Vec2 } from "@shift/geo"; +import { Point2D, Vec2 } from "@shift/geo"; import { Validate } from "@shift/validation"; import { STYLES, type CachedInstanceStyle } from "./handleStyles"; import { GPU_HANDLE_INSTANCE_FLOATS } from "./types"; import { getVisibleSceneBounds } from "../visibleSceneBounds"; import type { ViewportTransform } from "../Viewport"; +import { Contour } from "@shift/glyph-state"; const HANDLE_CULL_MARGIN_PX = 64; export function packHandleInstances( - glyph: Glyph, + contours: readonly Contour[], getHandleState: (pointId: PointId) => HandleState, viewport: ViewportTransform, drawOffset: Point2D, reusable: Float32Array | null, ): { packedInstances: Float32Array; instanceCount: number } { let totalPoints = 0; - for (const contour of glyph.contours) { + for (const contour of contours) { totalPoints += contour.points.length; } @@ -29,7 +29,7 @@ export function packHandleInstances( let index = 0; - for (const contour of glyph.contours) { + for (const contour of contours) { const points = contour.points; const numPoints = points.length; if (numPoints === 0) continue; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/types.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/types.ts index 3c25573d..802c7645 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/types.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/gpu/types.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { ViewportTransform } from "../Viewport"; export const GPU_HANDLE_INSTANCE_FLOATS = 25; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Anchors.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Anchors.ts index 3c3b6ee0..a2b987bc 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Anchors.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Anchors.ts @@ -1,15 +1,19 @@ import type { AnchorId } from "@shift/types"; import type { Canvas } from "../Canvas"; -import type { Glyph } from "@/lib/model/Glyph"; import type { HandleState } from "@/types/graphics"; import { drawHandle } from "./handleDrawing"; +import { Anchor } from "@shift/glyph-state"; /** * Draws glyph attachment anchors as diamond handles in UPM space. */ export class Anchors { - draw(canvas: Canvas, glyph: Glyph, getAnchorState: (anchorId: AnchorId) => HandleState): void { - for (const anchor of glyph.anchors) { + draw( + canvas: Canvas, + anchors: readonly Anchor[], + getAnchorState: (anchorId: AnchorId) => HandleState, + ): void { + for (const anchor of anchors) { drawHandle(canvas, { x: anchor.x, y: anchor.y }, "anchor", getAnchorState(anchor.id)); } } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/BoundingBox.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/BoundingBox.ts index 9eda16f7..29222f32 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/BoundingBox.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/BoundingBox.ts @@ -1,4 +1,4 @@ -import type { Rect2D } from "@shift/types"; +import type { Rect2D } from "@shift/geo"; import type { Canvas } from "../Canvas"; import { getHandlePositions } from "@/lib/editor/hit/boundingBox"; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/ControlLines.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/ControlLines.ts index e35b3169..daba1222 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/ControlLines.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/ControlLines.ts @@ -1,5 +1,5 @@ import type { Canvas } from "../Canvas"; -import type { Glyph } from "@/lib/model/Glyph"; +import { Contour } from "@shift/glyph-state"; import { Validate } from "@shift/validation"; /** @@ -9,7 +9,7 @@ import { Validate } from "@shift/validation"; export class ControlLines { draw( canvas: Canvas, - glyph: Glyph, + contours: readonly Contour[], isLineVisible?: (from: { x: number; y: number }, to: { x: number; y: number }) => boolean, ): void { const { stroke, widthPx } = canvas.theme.glyph; @@ -22,7 +22,7 @@ export class ControlLines { canvas.ctx.beginPath(); let hasLines = false; - for (const contour of glyph.contours) { + for (const contour of contours) { const points = contour.points; const len = points.length; if (len === 0) continue; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/DebugOverlays.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/DebugOverlays.ts index 88240311..8eaa5309 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/DebugOverlays.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/DebugOverlays.ts @@ -61,7 +61,7 @@ export class DebugOverlays { } #drawGlyphBbox(canvas: Canvas, glyph: Glyph, color: string): void { - const b = glyph.bbox; + const b = glyph.bounds; if (!b) return; canvas.strokeRect(b.min.x, b.min.y, b.max.x - b.min.x, b.max.y - b.min.y, color, 1); } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Segments.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Segments.ts index f35883ce..74d85cb5 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Segments.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Segments.ts @@ -1,5 +1,5 @@ import type { Canvas } from "../Canvas"; -import type { Segment } from "@/lib/model/Segment"; +import type { Segment } from "@shift/glyph-state"; export class Segments { draw(canvas: Canvas, hovered: Segment | null, selected: readonly Segment[]): void { diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/SnapLines.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/SnapLines.ts deleted file mode 100644 index 415ecb5f..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/SnapLines.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Point2D } from "@shift/types"; -import { Vec2 } from "@shift/geo"; -import type { Canvas } from "../Canvas"; -import type { SnapIndicator, SnapLine } from "../../snapping/types"; - -export class SnapLines { - draw(canvas: Canvas, indicator: SnapIndicator): void { - const { color, widthPx, crossSizePx } = canvas.theme.snap; - const crossHalf = canvas.pxToUpm(crossSizePx); - - for (const line of indicator.lines) { - canvas.line(line.from, line.to, color, widthPx); - } - - const markers = indicator.markers ?? collectEndpoints(indicator.lines); - const diagonal: Point2D = { x: crossHalf, y: crossHalf }; - const antiDiagonal: Point2D = { x: crossHalf, y: -crossHalf }; - - for (const m of markers) { - canvas.line(Vec2.sub(m, diagonal), Vec2.add(m, diagonal), color, widthPx); - canvas.line(Vec2.sub(m, antiDiagonal), Vec2.add(m, antiDiagonal), color, widthPx); - } - } -} - -function collectEndpoints(lines: ReadonlyArray): Point2D[] { - const markers: Point2D[] = []; - const seen = new Set(); - for (const line of lines) { - for (const endpoint of [line.from, line.to]) { - const key = `${endpoint.x}:${endpoint.y}`; - if (seen.has(key)) continue; - seen.add(key); - markers.push(endpoint); - } - } - return markers; -} diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/handleDrawing.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/handleDrawing.ts index 01309461..f9dc9860 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/handleDrawing.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/handleDrawing.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Canvas } from "../Canvas"; import type { HandleState, HandleType } from "@/types/graphics"; import type { HandleStyle } from "../Theme"; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/index.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/index.ts index fb9a8ad0..4a2485f0 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/index.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/indicators/index.ts @@ -1,6 +1,5 @@ export { Guides } from "./Guides"; export { BoundingBox } from "./BoundingBox"; -export { SnapLines } from "./SnapLines"; export { Segments } from "./Segments"; export { DebugOverlays } from "./DebugOverlays"; export { ControlLines } from "./ControlLines"; diff --git a/apps/desktop/src/renderer/src/lib/editor/snapping/SnapPipelineRunner.ts b/apps/desktop/src/renderer/src/lib/editor/snapping/SnapPipelineRunner.ts deleted file mode 100644 index 2f54cd87..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/snapping/SnapPipelineRunner.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Vec2 } from "@shift/geo"; -import type { - PointSnapStep, - PointSnapStepArgs, - PointSnap, - PointStep, - RotateSnapStep, - RotateSnapStepArgs, - RotateSnap, -} from "./types"; - -/** - * Executes an ordered list of snap steps and resolves the winning result. - * - * Stateless — all mutable context lives in the {@link SnapContext} inside `args`. - * The runner is shared across drag and rotate snap sessions; each session provides - * its own step list and args. - */ -export class SnapPipelineRunner { - /** - * Runs all point snap steps and returns the best result. - * - * Priority logic: - * 1. **Point-to-point** — if any step produces a `"pointToPoint"` result the - * pipeline short-circuits and that result wins immediately. - * 2. **Closest** — among remaining candidates (metrics, angle) the one nearest - * to the original point is chosen. - * 3. **No match** — returns the input point unchanged with `source: null`. - */ - runPointPipeline(steps: readonly PointSnapStep[], args: PointSnapStepArgs): PointSnap { - const candidates: PointStep[] = []; - - for (const step of steps) { - const result = step.apply(args); - if (result) { - candidates.push(result); - if (result.source === "pointToPoint") break; - } - } - - if (candidates.length === 0) { - return { point: args.point, source: null, indicator: null }; - } - - const p2p = candidates.find((c) => c.source === "pointToPoint"); - if (p2p) { - return { point: p2p.snappedPoint, source: p2p.source, indicator: p2p.indicator }; - } - - const best = candidates.reduce((a, b) => - Vec2.dist(a.snappedPoint, args.point) < Vec2.dist(b.snappedPoint, args.point) ? a : b, - ); - - return { point: best.snappedPoint, source: best.source, indicator: best.indicator }; - } - - /** - * Runs all rotate snap steps and returns the first match. - * - * Uses **first-match** semantics: the first step that returns a non-null result - * wins. If no step matches, the raw delta passes through with `source: null`. - */ - runRotatePipeline(steps: readonly RotateSnapStep[], args: RotateSnapStepArgs): RotateSnap { - for (const step of steps) { - const result = step.apply(args); - if (result) { - return { delta: result.snappedDelta, source: result.source }; - } - } - - return { delta: args.delta, source: null }; - } -} diff --git a/apps/desktop/src/renderer/src/lib/editor/snapping/steps.ts b/apps/desktop/src/renderer/src/lib/editor/snapping/steps.ts deleted file mode 100644 index 90fc2dad..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/snapping/steps.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Snap step factories and source collectors. - * - * Each factory returns a stateless {@link PointSnapStep} or {@link RotateSnapStep} - * that can be fed into {@link SnapPipelineRunner}. Steps are evaluated in pipeline - * order; the runner handles priority resolution (point-to-point wins, then closest - * for point pipelines; first-match for rotate pipelines). - * - * Steps are **pure** — all mutable state lives in the {@link SnapContext} passed - * through `args`. This keeps steps composable and testable in isolation. - */ -import { Vec2 } from "@shift/geo"; -import type { FontMetrics, Point2D } from "@shift/types"; -import type { PointSnapStep, RotateSnapStep, SnappableObject } from "./types"; - -/** - * Minimum distance (in UPM) below which a source point is treated as the - * dragged point itself and ignored. Prevents a point from snapping to its - * own position. - */ -const SELF_SNAP_EPSILON = 1e-6; - -/** - * Snaps the dragged point to the nearest anchor or control point within the - * snap radius. Skips self-matches via {@link SELF_SNAP_EPSILON}. Disabled when - * Shift is held (angle snap takes precedence). This is the highest-priority - * point step — the pipeline runner short-circuits when it matches. - */ -export function createPointToPointStep(): PointSnapStep { - return { - id: "pointToPoint", - apply: ({ point, modifiers, sources, preferences, radius }) => { - if (!preferences.enabled || !preferences.pointToPoint || modifiers.shiftKey) return null; - - let best: { point: Point2D; distance: number } | null = null; - for (const source of sources) { - if (source.kind !== "pointTarget") continue; - if (Vec2.dist(point, source.point) <= SELF_SNAP_EPSILON) continue; - const distance = Vec2.dist(point, source.point); - if (distance > radius) continue; - if (!best || distance < best.distance) { - best = { point: source.point, distance }; - } - } - - if (!best) return null; - return { - snappedPoint: best.point, - source: "pointToPoint", - indicator: { - lines: [{ from: point, to: best.point }], - markers: [{ x: best.point.x, y: best.point.y }], - }, - }; - }, - }; -} - -/** - * Snaps the dragged point's Y coordinate to the nearest horizontal font metric - * guide (baseline, x-height, cap-height, ascender, descender) within the snap - * radius. X is left unchanged. Disabled when Shift is held. - */ -export function createMetricsStep(): PointSnapStep { - return { - id: "metrics", - apply: ({ point, modifiers, sources, preferences, radius }) => { - if (!preferences.enabled || !preferences.metrics || modifiers.shiftKey) return null; - - let bestY: number | null = null; - let bestDist = Infinity; - - for (const source of sources) { - if (source.kind !== "metricGuide") continue; - const dist = Math.abs(point.y - source.y); - if (dist <= radius && dist < bestDist) { - bestY = source.y; - bestDist = dist; - } - } - - if (bestY === null) return null; - - const snappedPoint = { x: point.x, y: bestY }; - return { - snappedPoint, - source: "metrics", - indicator: { - lines: [{ from: { x: 0, y: bestY }, to: snappedPoint }], - }, - }; - }, - }; -} - -/** - * Constrains the drag vector from the reference point to the nearest angle - * increment (e.g. 15-degree steps). Only active when Shift is held. Uses - * hysteresis via {@link SnapContext.previousSnappedAngle} to prevent jitter - * near angle boundaries. - */ -export function createAngleStep(): PointSnapStep { - return { - id: "angle", - apply: ({ point, reference, modifiers, context, preferences, increment }) => { - if (!preferences.enabled || !preferences.angle || !modifiers.shiftKey) { - context.previousSnappedAngle = null; - return null; - } - - const delta = Vec2.sub(point, reference); - const snapped = Vec2.snapToAngleWithHysteresis( - delta, - context.previousSnappedAngle, - increment, - ); - const snappedPoint = Vec2.add(reference, snapped.position); - context.previousSnappedAngle = Vec2.angle(Vec2.sub(snappedPoint, reference)); - - return { - snappedPoint, - source: "angle", - indicator: { - lines: [{ from: reference, to: snappedPoint }], - }, - }; - }, - }; -} - -/** - * Quantizes a rotation delta to 15-degree increments when Shift is held. - * Uses hysteresis to avoid oscillation near snap boundaries. This is the - * rotate-pipeline counterpart of {@link createAngleStep}. - */ -export function createRotateAngleStep(): RotateSnapStep { - return { - id: "rotateAngle", - apply: ({ delta, modifiers, context, preferences }) => { - if (!preferences.enabled || !preferences.angle || !modifiers.shiftKey) { - context.previousSnappedAngle = null; - return null; - } - - const snappedDelta = Vec2.snapAngleWithHysteresis( - delta, - context.previousSnappedAngle, - Math.PI / 12, - ); - context.previousSnappedAngle = snappedDelta; - - return { - snappedDelta, - source: "angle", - indicator: null, - }; - }, - }; -} - -/** - * Converts {@link FontMetrics} into an array of `"metricGuide"` snap sources. - * Returns guides for baseline (y=0), x-height, cap-height, ascender, and descender. - * Returns an empty array when metrics are unavailable. - */ -export function collectMetricSources(metrics: FontMetrics): SnappableObject[] { - return [ - { kind: "metricGuide", y: 0, label: "baseline" }, - { kind: "metricGuide", y: metrics.xHeight ?? 0, label: "xHeight" }, - { kind: "metricGuide", y: metrics.capHeight ?? 0, label: "capHeight" }, - { kind: "metricGuide", y: metrics.ascender, label: "ascender" }, - { kind: "metricGuide", y: metrics.descender, label: "descender" }, - ]; -} diff --git a/apps/desktop/src/renderer/src/lib/editor/snapping/types.ts b/apps/desktop/src/renderer/src/lib/editor/snapping/types.ts deleted file mode 100644 index 1c25169c..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/snapping/types.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Snapping type definitions. - * - * The snapping system uses a **session-based API** backed by a **step pipeline**. - * Callers create a {@link DragSnapSession} or {@link RotateSnapSession}, then call - * `snap()` on each pointer/rotation event. Internally, the session feeds the input - * through an ordered list of {@link PointSnapStep} or {@link RotateSnapStep} objects. - * - * Resolution is **priority-based**: point-to-point snaps always win over metric or - * angle snaps; among the remaining candidates the closest result is chosen. Rotate - * pipelines use first-match semantics. - * - * All coordinates in these types are in **UPM space** unless noted otherwise. - * Screen-space conversion (e.g. snap radius) is handled by - * {@link Snap.screenToUpmDistance}. - */ -import type { Point2D, PointId } from "@shift/types"; -import type { SnapPreferences } from "@/types/editor"; - -/** A line segment between two points in UPM space. */ -export interface SnapLine { - from: Point2D; - to: Point2D; -} - -/** Visual feedback for an active snap: guide lines and optional target markers, both in UPM space. */ -export interface SnapIndicator { - lines: SnapLine[]; - markers?: Point2D[]; -} - -/** - * Mutable state carried across consecutive snap calls within a session. - * Stores the last snapped angle so hysteresis can prevent jitter near angle boundaries. - */ -export interface SnapContext { - previousSnappedAngle: number | null; -} - -/** Categories of snappable objects that can be requested when building the source list. */ -export type SnappableInclude = "points" | "metrics"; - -/** Describes which snap sources to gather and which points to exclude (typically the dragged points). */ -export interface SnappableQuery { - include: readonly SnappableInclude[]; - excludedPointIds?: readonly PointId[]; -} - -/** - * A target that can attract a dragged point. - * - `"pointTarget"` — an anchor or control point in the glyph (UPM coords). - * - `"metricGuide"` — a horizontal font metric line (baseline, x-height, etc.). - */ -export type SnappableObject = - | { kind: "pointTarget"; id: PointId; point: Point2D } - | { kind: "metricGuide"; y: number; label: string }; - -/** - * Input bundle passed to each {@link PointSnapStep} in the pipeline. - * Contains the candidate point, its drag reference origin, modifier keys, - * accumulated context, all snap sources, user preferences, and thresholds. - */ -export interface PointSnapStepArgs { - point: Point2D; - reference: Point2D; - modifiers: { shiftKey: boolean }; - context: SnapContext; - sources: readonly SnappableObject[]; - preferences: SnapPreferences; - radius: number; - increment: number; -} - -/** Output of a single point snap step: the corrected position, which source matched, and optional visual indicator. */ -export interface PointStep { - snappedPoint: Point2D; - source: "pointToPoint" | "metrics" | "angle"; - indicator: SnapIndicator | null; -} - -/** - * A single stage in the point snap pipeline. Each step inspects the input and - * either returns a {@link PointStep} (snap hit) or `null` (no match). - */ -export interface PointSnapStep { - id: string; - apply(args: PointSnapStepArgs): PointStep | null; -} - -/** Input bundle passed to each {@link RotateSnapStep}. Contains the raw rotation delta (radians) and modifier state. */ -export interface RotateSnapStepArgs { - delta: number; - modifiers: { shiftKey: boolean }; - context: SnapContext; - preferences: SnapPreferences; - increment: number; -} - -/** Output of a single rotate snap step: the quantized delta and its source. */ -export interface RotateStep { - snappedDelta: number; - source: "angle"; - indicator: SnapIndicator | null; -} - -/** - * A single stage in the rotate snap pipeline. Returns a {@link RotateStep} - * when the rotation delta should be quantized, or `null` to pass through. - */ -export interface RotateSnapStep { - id: string; - apply(args: RotateSnapStepArgs): RotateStep | null; -} - -/** Final resolved result of a point snap pipeline run. If no step matched, `source` is `null` and `point` is unchanged. */ -export interface PointSnap { - point: Point2D; - indicator: SnapIndicator | null; - source: "pointToPoint" | "metrics" | "angle" | null; -} - -/** Final resolved result of a rotate snap pipeline run. If no step matched, `source` is `null` and `delta` is unchanged. */ -export interface RotateSnap { - delta: number; - source: "angle" | null; -} - -/** - * Configuration for creating a {@link DragSnapSession}. - * Identifies the anchor being dragged and which points to exclude from snap targets. - */ -export interface DragSnapSessionConfig { - anchorPointId: PointId; - dragStart: Point2D; - excludedPointIds?: readonly PointId[]; -} - -/** - * Stateful session for snapping a dragged point. Created once at drag start; - * call `snap()` on each pointer move and `clear()` when the drag ends. - * The session owns the {@link SnapContext} that provides angle hysteresis. - */ -export interface DragSnapSession { - getAnchorPosition(): Point2D; - snap(point: Point2D, modifiers: { shiftKey: boolean }): PointSnap; - clear(): void; -} - -/** - * Stateful session for snapping a rotation delta. Created once at rotate start; - * call `snap()` on each rotation event and `clear()` when rotation ends. - */ -export interface RotateSnapSession { - snap(delta: number, modifiers: { shiftKey: boolean }): RotateSnap; - clear(): void; -} diff --git a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts index 59f54633..c5970a37 100644 --- a/apps/desktop/src/renderer/src/lib/editor/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/variation.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from "vitest"; -import type { AxisLocation } from "@shift/types"; -import type { Glyph } from "@/lib/model/Glyph"; +import type { Glyph, GlyphGeometry } from "@/lib/model/Glyph"; import { TestEditor, MUTATORSANS_DESIGNSPACE } from "@/testing"; +import { emptyAxisLocation, withAxisValue } from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; function boldLocation(editor: TestEditor): AxisLocation { - const out: AxisLocation = {}; - for (const axis of editor.font.getAxes()) out[axis.tag] = axis.maximum; + let out = emptyAxisLocation(); + for (const axis of editor.font.getAxes()) { + out = withAxisValue(out, axis, axis.maximum); + } return out; } -function flattenPoints(g: Glyph): number[] { +function flattenPoints(g: Glyph | GlyphGeometry): number[] { const out: number[] = []; for (const c of g.contours) for (const p of c.points) out.push(p.x, p.y); return out; @@ -21,17 +24,67 @@ describe("Editor.open — variation-aware edit sessions", () => { // edit session at the master's stored coordinates, so the canvas didn't // match what the user clicked when the slider was off-default. const editor = new TestEditor(); - editor.font.load(MUTATORSANS_DESIGNSPACE); + editor.loadFont(MUTATORSANS_DESIGNSPACE); - const atDefault = editor.open({ glyphName: "A", unicode: 65 })!; - const defaultAdvance = atDefault.xAdvance; - const defaultPoints = flattenPoints(atDefault); + const atDefault = editor.getGlyph({ name: "A", unicode: 65 })!; + const defaultGeometry = atDefault.geometryAt(editor.designLocation); + const defaultAdvance = defaultGeometry.xAdvance; + const defaultPoints = flattenPoints(defaultGeometry); editor.close(); - editor.font.setVariationLocation(boldLocation(editor)); - const atBold = editor.open({ glyphName: "A", unicode: 65 })!; + editor.setDesignLocation(boldLocation(editor)); + const atBold = editor.getGlyph({ name: "A", unicode: 65 })!; - expect(atBold.xAdvance).not.toBe(defaultAdvance); - expect(flattenPoints(atBold)).not.toEqual(defaultPoints); + const boldGeometry = atBold.geometryAt(editor.designLocation); + expect(boldGeometry.xAdvance).not.toBe(defaultAdvance); + expect(flattenPoints(boldGeometry)).not.toEqual(defaultPoints); + }); + + it("edits to a glyph persist across close + reopen of that same glyph", () => { + // Reproduces the user-reported flow: edit a point, leave the editor (back + // to the grid), then re-open the same glyph. The re-opened glyph should + // carry the edit, not revert to the unedited geometry. + const editor = new TestEditor(); + editor.loadFont(MUTATORSANS_DESIGNSPACE); + + const opened = editor.getGlyph({ name: "A", unicode: 65 })!; + const point = opened.contours[0].points[0]; + const movedX = point.x + 250; + + const draft = editor.beginSourceEditDraft({ points: [point.id] }); + draft.previewPositions([{ kind: "point", id: point.id, x: movedX, y: point.y }]); + draft.commit("Move"); + + editor.close(); + + const reopened = editor.getGlyph({ name: "A", unicode: 65 })!; + const samePoint = reopened.point(point.id); + + expect(samePoint?.x).toBe(movedX); + }); + + it("edits to a glyph are visible from the registry after closing the session", () => { + // The grid renders via `font.glyph(name)` (not the editor) — so after a + // session ends, the registry's Glyph must reflect the edits the user + // just made. Otherwise the grid shows the pre-edit outline. + const editor = new TestEditor(); + editor.loadFont(MUTATORSANS_DESIGNSPACE); + + const opened = editor.getGlyph({ name: "A", unicode: 65 })!; + const point = opened.contours[0].points[0]; + const movedX = point.x + 250; + + const draft = editor.beginSourceEditDraft({ points: [point.id] }); + draft.previewPositions([{ kind: "point", id: point.id, x: movedX, y: point.y }]); + draft.commit("Move"); + + editor.close(); + + // Same Glyph instance the grid would read — registry single-source-of-truth. + const source = editor.font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const fromRegistry = editor.font.glyph({ name: "A" })!; + expect(fromRegistry.outline(editor.$designLocation).svgPath).toContain(String(movedX)); }); }); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index aaf5cde0..ac0521b7 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -22,7 +22,7 @@ interface Fixture { } function loadFixture(): Fixture { - // Generated by `cargo test -p shift-core --test interpolation_parity`. + // Generated by `cargo test -p shift-edit --test interpolation_parity`. // vitest runs with cwd at apps/desktop; repo root is two up. const path = resolve(process.cwd(), "../../packages/types/__fixtures__/variation_parity.json"); return JSON.parse(readFileSync(path, "utf-8")) as Fixture; diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 30531347..d0e7b09d 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -7,10 +7,12 @@ * `scalar_at_with_args` and `interpolate_from_deltas`. * * Parity-tested against `packages/types/__fixtures__/variation_parity.json`, - * generated by `crates/shift-core/tests/interpolation_parity.rs`. + * generated by `crates/shift-edit/tests/interpolation_parity.rs`. */ -import type { Axis, AxisLocation, AxisTent, GlyphVariationData } from "@shift/types"; +import type { Axis, AxisTent, GlyphVariationData } from "@shift/types"; +import { axisValue } from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; export type NormalizedLocation = Record; @@ -30,7 +32,7 @@ export function normalizeAxis(value: number, axis: Axis): number { export function normalize(loc: AxisLocation, axes: Axis[]): NormalizedLocation { const out: NormalizedLocation = {}; for (const axis of axes) { - out[axis.tag] = normalizeAxis(loc[axis.tag] ?? axis.default, axis); + out[axis.tag] = normalizeAxis(axisValue(loc, axis), axis); } return out; } @@ -74,7 +76,7 @@ export function scalarAt(loc: NormalizedLocation, region: AxisTent[]): number { * Output values are absolute (not offsets from default) because the default * master's region has empty/zero support and contributes its full delta at scalar=1. * - * Order is the `flatten()` shape from shift-core::interpolation: + * Order is the `flatten()` shape from shift-edit::interpolation: * [xAdvance, p0.x, p0.y, p1.x, p1.y, ..., a0.x, a0.y, ...] */ export function interpolate(data: GlyphVariationData, loc: NormalizedLocation): Float64Array { diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts new file mode 100644 index 00000000..9e38bd34 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { createBridge } from "@shift/bridge"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import { + axisLocationFromLocation, + axisValue, + defaultAxisLocation, + emptyAxisLocation, + withAxisValue, +} from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; +import { signal } from "@/lib/signals/signal"; +import { Font } from "./Font"; + +function loadFont(): Font { + const font = new Font(createBridge()); + font.load(MUTATORSANS_DESIGNSPACE); + return font; +} + +function locationOverride(font: Font, override: Record): AxisLocation { + let location = defaultAxisLocation(font.getAxes()); + for (const axis of font.getAxes()) { + if (override[axis.tag] !== undefined) { + location = withAxisValue(location, axis, override[axis.tag]); + } + } + return location; +} + +describe("Font", () => { + it("exposes bridge default metrics before a font is loaded", () => { + const font = new Font(createBridge()); + + expect(font.loaded).toBe(false); + expect(font.metrics.unitsPerEm).toBe(1000); + expect(font.metrics.ascender).toBe(800); + expect(font.metrics.descender).toBe(-200); + }); + + it("hydrates committed glyph directory records from the bridge", () => { + const font = loadFont(); + + expect(font.loaded).toBe(true); + expect(font.glyphRecords().length).toBeGreaterThan(0); + expect(font.metrics.unitsPerEm).toBeGreaterThan(0); + }); + + it("resolves glyph handles by name and unicode", () => { + const font = loadFont(); + + expect(font.glyphHandleForName("A")).toEqual({ name: "A", unicode: 65 }); + expect(font.glyphHandleForUnicode(65)).toEqual({ name: "A", unicode: 65 }); + expect(font.nameForUnicode(65)).toBe("A"); + expect(font.glyphHandleForName("notdef")).toBeNull(); + expect(font.glyphHandleForUnicode(0xffff)).toBeNull(); + }); + + it("exposes component dependency information from glyph records", () => { + const font = loadFont(); + const bases = font.componentBaseNamesForName("Aacute"); + + expect(bases.length).toBeGreaterThan(0); + expect(font.dependentNamesForName(bases[0])).toContain("Aacute"); + }); + + it("returns a stable Glyph instance per glyph name", () => { + const font = loadFont(); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const a = font.glyph({ name: "A", unicode: 65 }); + + expect(a).not.toBeNull(); + expect(font.glyph({ name: "A" })).toBe(a); + }); + + it("returns a stable GlyphSource instance per glyph source", () => { + const font = loadFont(); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const a = font.glyphSource({ name: "A", unicode: 65 }, source); + + expect(a).not.toBeNull(); + expect(font.glyphSource({ name: "A" }, source)).toBe(a); + }); + + it("exposes variation defaults as a typed design location", () => { + const font = loadFont(); + const location = font.defaultLocation(); + + expect(font.isVariable()).toBe(true); + for (const axis of font.getAxes()) { + expect(axisValue(location, axis)).toBe(axis.default); + } + }); + + it("looks up sources by id and exact design location", () => { + const font = loadFont(); + const source = font.sources[0]; + + expect(source).toBeDefined(); + expect(font.source(source.id)).toEqual(source); + expect(font.sourceAt(axisLocationFromLocation(source.location))?.id).toBe(source.id); + }); + + it("matches omitted location axes against axis defaults", () => { + const font = loadFont(); + const defaultSource = font.sources.find((source) => + font + .getAxes() + .every( + (axis) => axisValue(axisLocationFromLocation(source.location), axis) === axis.default, + ), + ); + + expect(defaultSource).toBeDefined(); + expect(font.sourceAt(emptyAxisLocation())?.id).toBe(defaultSource?.id); + }); + + it("distinguishes in-between locations from editable source locations", () => { + const font = loadFont(); + const inBetween = locationOverride(font, { wdth: 500, wght: 500 }); + + expect(font.sourceAt(inBetween)).toBeNull(); + expect(font.nearestSource(inBetween)).not.toBeNull(); + }); + + it("reset clears loaded directory state", () => { + const font = loadFont(); + const location = signal(font.defaultLocation()); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const glyph = font.glyph({ name: "A", unicode: 65 }); + + expect(glyph ? glyph.outline(location).svgPath.length : 0).toBeGreaterThan(0); + font.reset(); + + expect(font.loaded).toBe(false); + expect(font.glyphRecords()).toEqual([]); + expect(font.glyphHandleForName("A")).toBeNull(); + expect(font.metrics.unitsPerEm).toBe(1000); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index f0789231..9b0a1580 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,44 +1,159 @@ import type { FontMetrics, FontMetadata, - CompositeGlyph, Axis, - AxisLocation, Source, GlyphVariationData, + GlyphVariationReport, + GlyphRecord, + GlyphState, + GlyphName, + SourceId, + Unicode, } from "@shift/types"; -import type { MasterSnapshot } from "@shift/types"; -import type { Bounds } from "@shift/geo"; -import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; -import type { NativeBridge } from "@/bridge"; -import { getGlyphInfo } from "@/store/glyphInfo"; -import { LruCache } from "@/lib/utils/LruCache"; -import { GlyphView } from "./GlyphView"; +import { computed, signal, type WritableSignal, type Signal } from "@/lib/signals/signal"; +import { Glyph, type GlyphSource } from "./Glyph"; +import type { GlyphHandle, ShiftBridge } from "@shift/bridge"; +import { + axisLocationDistanceSquared, + axisLocationFromLocation, + axisLocationsEqual, + defaultAxisLocation, + emptyAxisLocation, +} from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; + +class GlyphDirectory { + readonly records: readonly GlyphRecord[]; + readonly unicodes: readonly Unicode[]; + + readonly recordsByName: ReadonlyMap = new Map(); + readonly nameByUnicode: ReadonlyMap = new Map(); + readonly componentBasesByName: ReadonlyMap = new Map(); + readonly dependentsByName: ReadonlyMap> = new Map(); + + private constructor(records: readonly GlyphRecord[]) { + const recordsByName = new Map(); + const nameByUnicode = new Map(); + const componentBasesByName = new Map(); + const dependentsByName = new Map>(); + + for (const record of records) { + recordsByName.set(record.name, record); + + for (const unicode of record.unicodes) { + if (!nameByUnicode.has(unicode)) { + nameByUnicode.set(unicode, record.name); + } + } + componentBasesByName.set(record.name, record.componentBaseGlyphNames); + for (const baseName of record.componentBaseGlyphNames) { + let dependents = dependentsByName.get(baseName); + if (!dependents) { + dependents = new Set(); + dependentsByName.set(baseName, dependents); + } + dependents.add(record.name); + } + } + + this.records = [...records]; + this.unicodes = [...nameByUnicode.keys()].sort((a, b) => a - b); + this.recordsByName = recordsByName; + this.nameByUnicode = nameByUnicode; + this.componentBasesByName = componentBasesByName; + this.dependentsByName = dependentsByName; + } + + static fromRecords(records: readonly GlyphRecord[]): GlyphDirectory { + return new GlyphDirectory(records); + } + + static empty(): GlyphDirectory { + return new GlyphDirectory([]); + } + + nameForUnicode(unicode: Unicode): GlyphName | null { + return this.nameByUnicode.get(unicode) ?? null; + } + + /** @knipclassignore — public glyph directory API. */ + hasGlyph(name: GlyphName): boolean { + return this.recordsByName.has(name); + } + + /** @knipclassignore — public glyph directory API. */ + recordForName(name: GlyphName): GlyphRecord | null { + return this.recordsByName.get(name) ?? null; + } + + /** @knipclassignore — public glyph directory API. */ + unicodesForName(name: GlyphName): readonly Unicode[] { + return this.recordsByName.get(name)?.unicodes ?? []; + } + + /** @knipclassignore — public glyph directory API. */ + primaryUnicodeForName(name: GlyphName): Unicode | null { + return this.unicodesForName(name)[0] ?? null; + } + + allUnicodes(): readonly Unicode[] { + return this.unicodes; + } + + componentBaseNamesForName(name: GlyphName): readonly GlyphName[] { + return this.componentBasesByName.get(name) ?? []; + } -const GLYPH_CACHE_CAPACITY = 256; + dependentNamesForName(name: GlyphName): readonly GlyphName[] { + return [...(this.dependentsByName.get(name) ?? [])].sort(); + } + + glyphHandleForName(name: GlyphName): GlyphHandle | null { + const record = this.recordForName(name); + if (!record) return null; + const unicode = this.primaryUnicodeForName(name); + return unicode === null ? { name } : { name, unicode }; + } + + glyphHandleForUnicode(unicode: Unicode): GlyphHandle | null { + const name = this.nameForUnicode(unicode); + return name ? { name, unicode } : null; + } +} + +type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; /** * Reactive font data surface. * * Auto-unwrapping getters (same pattern as Glyph). Reading `font.metrics`, * `font.unicodes`, `font.loaded` inside a computed/effect auto-tracks. + * + * Owns glyph identity and glyph-source registries, lazily seeded from the + * bridge. Glyphs are cached by name. Editable glyph sources are cached by + * glyph name plus source id. */ export class Font { - readonly #bridge: NativeBridge; + readonly #bridge: ShiftBridge; + readonly #defaultMetrics: FontMetrics; + readonly #$loaded: WritableSignal; - readonly #$unicodes: WritableSignal; - readonly #$metrics: WritableSignal; - readonly #$variationLocation: WritableSignal; + readonly #$metrics: WritableSignal; + readonly #$sources: WritableSignal; + readonly #$unicodes: Signal; - readonly #glyphs: LruCache; + readonly #directory = signal(GlyphDirectory.empty()); + readonly #glyphs = new Map(); + readonly #glyphSources = new Map(); - constructor(bridge: NativeBridge) { + constructor(bridge: ShiftBridge) { this.#bridge = bridge; + this.#defaultMetrics = bridge.getMetrics(); this.#$loaded = signal(false); - this.#$unicodes = signal([]); - this.#$metrics = signal(null); - this.#$variationLocation = signal({}); - this.#glyphs = new LruCache(GLYPH_CACHE_CAPACITY, (g) => g.dispose()); + this.#$metrics = signal(this.#defaultMetrics); + this.#$sources = signal([]); + this.#$unicodes = computed(() => [...this.#directory.value.unicodes]); } /** @knipclassignore */ @@ -47,13 +162,13 @@ export class Font { } /** @knipclassignore */ - get unicodes(): number[] { - return this.#$unicodes.value; + get metrics(): FontMetrics { + return this.#$metrics.value; } /** @knipclassignore */ - get metrics(): FontMetrics | null { - return this.#$metrics.value; + get unicodes(): readonly Unicode[] { + return this.#directory.value.unicodes; } /** Raw signals for React hooks that need Signal. */ @@ -63,13 +178,13 @@ export class Font { } /** @knipclassignore */ - get $unicodes() { - return this.#$unicodes as Signal; + get $metrics() { + return this.#$metrics as Signal; } /** @knipclassignore */ - get $metrics() { - return this.#$metrics as Signal; + get $unicodes(): Signal { + return this.#$unicodes; } /** @knipclassignore */ @@ -77,62 +192,144 @@ export class Font { return this.#bridge.getMetadata(); } - /** Sync metrics fetch (non-null, call only when font is loaded). */ - getMetrics(): FontMetrics { - return this.#bridge.getMetrics(); + get bridge(): ShiftBridge { + return this.#bridge; + } + + glyphRecords(): readonly GlyphRecord[] { + return this.#directory.value.records; + } + + nameForUnicode(unicode: Unicode): GlyphName | null { + return this.#directory.value.nameForUnicode(unicode); + } + + /** @knipclassignore — public glyph directory API. */ + hasGlyph(name: GlyphName): boolean { + return this.#directory.value.hasGlyph(name); } - /** @knipclassignore — read by canvas drawers and TextLayout.shapeHitTest */ - getPath(name: string): Path2D | null { - return this.#bridge.getPath(name); + /** @knipclassignore — public glyph directory API. */ + recordForName(name: GlyphName): GlyphRecord | null { + return this.#directory.value.recordForName(name); } - nameForUnicode(unicode: number): string | null { - return this.#bridge.nameForUnicode(unicode); + /** @knipclassignore — public glyph directory API. */ + unicodesForName(name: GlyphName): readonly Unicode[] { + return this.#directory.value.unicodesForName(name); } - glyph(name: string): GlyphView | null { - const cached = this.#glyphs.get(name); + /** @knipclassignore — public glyph directory API. */ + primaryUnicodeForName(name: GlyphName): Unicode | null { + return this.#directory.value.primaryUnicodeForName(name); + } + + componentBaseNamesForName(name: GlyphName): readonly GlyphName[] { + return this.#directory.value.componentBaseNamesForName(name); + } + + dependentNamesForName(name: GlyphName): readonly GlyphName[] { + return this.#directory.value.dependentNamesForName(name); + } + + glyphHandleForName(name: GlyphName): GlyphHandle | null { + return this.#directory.value.glyphHandleForName(name); + } + + glyphHandleForUnicode(unicode: Unicode): GlyphHandle | null { + return this.#directory.value.glyphHandleForUnicode(unicode); + } + + glyph(handle: GlyphHandle): Glyph | null { + const cached = this.#glyphs.get(handle.name); if (cached) return cached; - const data = this.#bridge.getGlyphData(name); - if (!data) return null; + const source = this.defaultSource(); + if (!source) return null; + const state = this.glyphState(handle, source); + if (!state) return null; + + const glyph = new Glyph(this, handle, source, state); + this.#glyphs.set(handle.name, glyph); + return glyph; + } + + glyphSource(handle: GlyphHandle, source: Source): GlyphSource | null { + if (!this.source(source.id)) return null; + + const key = glyphSourceKey(handle.name, source.id); + const cached = this.#glyphSources.get(key); + if (cached) return cached; + + const glyph = this.glyph(handle); + if (!glyph) return null; + + const state = glyph.isPrimarySource(source) ? undefined : this.glyphState(handle, source); + const glyphSource = glyph.createGlyphSource(source, state); + if (!glyphSource) return null; + + this.#glyphSources.set(key, glyphSource); + return glyphSource; + } - const g = new GlyphView( - name, - data.geometry, - data.variationData, - data.components, - this.getAxes(), - this.#$variationLocation, - this, - ); + glyphState(handle: GlyphHandle, source: Source): GlyphState | null { + try { + return this.#bridge.getGlyphState(handle, source.id); + } catch { + return null; + } + } - this.#glyphs.set(name, g); - return g; + defaultSource(): Source | null { + return this.sourceAt(this.defaultLocation()) ?? this.sources[0] ?? null; } - /** Resolve unicode to glyph name. Checks font first, then glyph-info DB, then fallback. */ - glyphName(unicode: number): string { - return ( - this.#bridge.nameForUnicode(unicode) ?? - getGlyphInfo().getGlyphName(unicode) ?? - `uni${unicode.toString(16).padStart(4, "0").toUpperCase()}` - ); + source(sourceId: SourceId): Source | null { + const sources = this.sources; + + for (const source of sources) { + if (source.id === sourceId) { + return source; + } + } + + return null; } - getBbox(name: string): Bounds | null { - return this.#bridge.getBbox(name); + sourceAt(location: AxisLocation): Source | null { + const axes = this.getAxes(); + const sources = this.sources; + + for (const source of sources) { + const sourceLocation = axisLocationFromLocation(source.location); + if (axisLocationsEqual(sourceLocation, location, axes)) { + return source; + } + } + + return null; } - /** @knipclassignore — used by GlyphPreview for variation interpolation */ - get $variationLocation(): Signal { - return this.#$variationLocation; + sourceAtOrDefault(location: AxisLocation): Source | null { + return this.sourceAt(location) ?? this.defaultSource(); } - /** @knipclassignore — used by useVariationLocation */ - setVariationLocation(location: AxisLocation): void { - this.#$variationLocation.set(location); + nearestSource(location: AxisLocation): Source | null { + const axes = this.getAxes(); + let nearest: { source: Source; distance: number } | null = null; + + for (const source of this.sources) { + const sourceLocation = axisLocationFromLocation(source.location); + const distance = axisLocationDistanceSquared(sourceLocation, location, axes); + + if (!nearest || distance < nearest.distance) { + nearest = { source, distance }; + } + } + + if (!nearest) return null; + + return nearest.source; } /** @knipclassignore — used by VariationPanel component */ @@ -146,51 +343,58 @@ export class Font { } /** @knipclassignore — used by VariationPanel component */ - getSources(): Source[] { - return this.#bridge.getSources(); + get sources(): Source[] { + return this.#$sources.value; } /** @knipclassignore — used by VariationPanel component */ - getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { - return this.#bridge.getGlyphMasterSnapshots(glyphName); + getGlyphVariationData(_handle: GlyphHandle): GlyphVariationData | null { + return null; } - /** @knipclassignore — used by VariationPanel component */ - getGlyphVariationData(glyphName: string): GlyphVariationData | null { - return this.#bridge.getGlyphVariationData(glyphName); + /** @knipclassignore — used by DebugPanel component */ + getGlyphVariationReport(handle: GlyphHandle): GlyphVariationReport | null { + return this.#bridge.getGlyphVariationReport(handle); } - composites(glyphName: string): CompositeGlyph | null { - return this.#bridge.getGlyphCompositeComponents(glyphName) as CompositeGlyph | null; + /** @knipclassignore — used by DebugPanel component */ + getVariationReports(): GlyphVariationReport[] { + return this.#bridge.getVariationReports(); } load(path: string): void { + this.#glyphs.clear(); + this.#glyphSources.clear(); this.#bridge.loadFont(path); - const unicodes = this.#bridge.getGlyphUnicodes(); - const metrics = this.#bridge.getMetrics(); - this.#$unicodes.set(unicodes); - this.#$metrics.set(metrics); - this.#$variationLocation.set(this.#defaultLocation()); + + this.#directory.set(GlyphDirectory.fromRecords(this.#bridge.getGlyphs())); + this.#$metrics.set(this.#bridge.getMetrics()); + this.#$sources.set(this.#bridge.getSources()); + this.#$loaded.set(true); } - async save(path: string): Promise { - return this.#bridge.saveFontAsync(path); + async save(path: string): Promise { + return this.#bridge.saveFont(path); } /** @knipclassignore — called when closing a document */ reset(): void { - this.#glyphs.clear(); this.#$loaded.set(false); - this.#$unicodes.set([]); - this.#$metrics.set(null); - this.#$variationLocation.set({}); + + this.#glyphs.clear(); + this.#glyphSources.clear(); + this.#directory.set(GlyphDirectory.empty()); + + this.#$metrics.set(this.#defaultMetrics); + this.#$sources.set([]); } - #defaultLocation(): AxisLocation { - if (!this.isVariable()) return {}; - const out: AxisLocation = {}; - for (const axis of this.#bridge.getAxes()) out[axis.tag] = axis.default; - return out; + defaultLocation(): AxisLocation { + return this.isVariable() ? defaultAxisLocation(this.#bridge.getAxes()) : emptyAxisLocation(); } } + +function glyphSourceKey(name: GlyphName, sourceId: SourceId): GlyphSourceKey { + return `${sourceId}:${name}` as GlyphSourceKey; +} diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts new file mode 100644 index 00000000..7522f9f6 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createBridge, type ShiftBridge } from "@shift/bridge"; +import { effect, signal } from "@/lib/signals/signal"; +import { defaultAxisLocation, withAxisValue } from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import { Font } from "./Font"; +import type { Glyph, GlyphSource } from "./Glyph"; +import type { Point } from "@shift/glyph-state"; + +function editGlyph(): { bridge: ShiftBridge; font: Font; glyph: Glyph; layer: GlyphSource } { + const bridge = createBridge(); + const font = new Font(bridge); + font.load(MUTATORSANS_DESIGNSPACE); + + const handle = { name: "A" }; + const source = font.sourceAt(font.defaultLocation()) ?? font.sources[0]; + if (!source) throw new Error("Expected editable source"); + bridge.startEditSession(handle, source.id); + + const glyph = font.glyph(handle); + if (!glyph) throw new Error("Expected edit glyph"); + const layer = font.glyphSource(handle, source); + if (!layer) throw new Error("Expected edit glyph source"); + + return { bridge, font, glyph, layer }; +} + +function addTriangle(layer: GlyphSource): readonly Point[] { + const contourId = layer.addContour(); + + layer.addPoint(contourId, { x: 0, y: 0, pointType: "onCurve", smooth: false }); + layer.addPoint(contourId, { x: 100, y: 0, pointType: "onCurve", smooth: false }); + layer.addPoint(contourId, { x: 50, y: 100, pointType: "onCurve", smooth: false }); + + layer.closeContour(contourId); + + const contour = layer.contours.at(-1); + if (!contour) throw new Error("Expected created contour"); + return contour.points; +} + +function loadMutatorSans(): Font { + const font = new Font(createBridge()); + font.load(MUTATORSANS_DESIGNSPACE); + + return font; +} + +function locationOverride(font: Font, override: Record): AxisLocation { + let location = defaultAxisLocation(font.getAxes()); + for (const axis of font.getAxes()) { + if (override[axis.tag] !== undefined) { + location = withAxisValue(location, axis, override[axis.tag]); + } + } + + return location; +} + +describe("Glyph", () => { + let glyph: Glyph; + let layer: GlyphSource; + + beforeEach(() => { + const { glyph: nextGlyph, layer: nextLayer } = editGlyph(); + + glyph = nextGlyph; + layer = nextLayer; + }); + + it("hydrates state from the active edit session", () => { + expect(glyph.name).toBe("A"); + expect(glyph.unicode).toBeNull(); + expect(glyph.xAdvance).toBeGreaterThan(0); + expect(glyph.contours.length).toBeGreaterThan(0); + }); + + it("applies structural edits returned by the bridge", () => { + const points = addTriangle(layer); + + expect(layer.contours.at(-1)?.closed).toBe(true); + + expect(points.map((point) => [point.x, point.y])).toEqual([ + [0, 0], + [100, 0], + [50, 100], + ]); + }); + + it("updates positions through the packed setPositions path", () => { + const [first] = addTriangle(layer); + + layer.setPositions([{ kind: "point", id: first.id, x: 25, y: 75 }]); + + expect(glyph.point(first.id)).toMatchObject({ x: 25, y: 75 }); + }); + + it("exposes glyph sources as the authored geometry surface", () => { + const font = loadMutatorSans(); + const source = font.sourceAt(font.defaultLocation()); + if (!source) throw new Error("Expected source"); + + const glyph = font.glyph({ name: "A" }); + + expect(glyph).not.toBeNull(); + + const glyphSource = font.glyphSource({ name: "A" }, source); + expect(glyphSource).not.toBeNull(); + expect(glyphSource.source).toBe(source); + + const geometry = glyph!.geometryAt(font.defaultLocation()); + expect(glyphSource.geometry.xAdvance).toBe(geometry.xAdvance); + }); + + it("feeds reactive geometry consumers when positions change", () => { + const { glyph, layer } = editGlyph(); + const first = glyph.allPoints[0]; + if (!first) throw new Error("Expected point"); + let pointX = first.x; + + const subscription = effect(() => { + pointX = glyph.point(first.id)?.x ?? pointX; + }); + + layer.setPositions([{ kind: "point", id: first.id, x: 33, y: 44 }]); + + expect(pointX).toBe(33); + subscription.dispose(); + }); + + it("returns a serializable state for restore", () => { + const [first] = addTriangle(layer); + const state = glyph.toState(); + + layer.setPositions([{ kind: "point", id: first.id, x: 300, y: 400 }]); + layer.restore(state); + + expect(glyph.point(first.id)).toMatchObject({ x: 0, y: 0 }); + }); +}); + +describe("Glyph variation interpolation", () => { + let font: Font; + let glyph: Glyph; + + beforeEach(() => { + font = loadMutatorSans(); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const nextGlyph = font.glyph({ name: "A" }); + if (!nextGlyph) throw new Error("Expected glyph"); + glyph = nextGlyph; + }); + + it("returns glyph sources for matching font sources", () => { + const sources = font.sources; + expect(sources.length).toBeGreaterThan(0); + + const resolved = []; + for (const source of sources) { + const glyphSource = font.glyphSource(glyph.handle, source); + if (glyphSource) resolved.push(glyphSource); + } + + expect(resolved.length).toBeGreaterThan(0); + expect(resolved.every((glyphSource) => glyphSource.source.id === glyphSource.id)).toBe(true); + }); + + it("root glyph svgPath changes when the variation location moves", () => { + const location = signal(font.defaultLocation()); + const atDefault = glyph!.outline(location).svgPath; + const axes = font.getAxes(); + location.set(locationOverride(font, Object.fromEntries(axes.map((a) => [a.tag, a.maximum])))); + + expect(glyph!.outline(location).svgPath).not.toBe(atDefault); + }); + + it("returns concrete shapes at requested design locations", () => { + const axes = font.getAxes(); + const atDefault = glyph!.instanceAt(font.defaultLocation()).geometry; + const atMaximum = glyph!.instanceAt( + locationOverride(font, Object.fromEntries(axes.map((axis) => [axis.tag, axis.maximum]))), + ).geometry; + + expect(atMaximum.xAdvance).not.toBe(atDefault.xAdvance); + expect(atMaximum.contours.flatMap((contour) => contour.points)).not.toEqual( + atDefault.contours.flatMap((contour) => contour.points), + ); + }); + + it("pure composites include component geometry in svgPath", () => { + const font = loadMutatorSans(); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + + const glyph = font.glyph({ name: "Aacute" }); + expect(glyph).not.toBeNull(); + + const location = signal(font.defaultLocation()); + const atDefault = glyph!.outline(location).svgPath; + const axes = font.getAxes(); + location.set(locationOverride(font, Object.fromEntries(axes.map((a) => [a.tag, a.maximum])))); + + expect(glyph!.contours).toEqual([]); + expect(atDefault.length).toBeGreaterThan(0); + expect(glyph!.outline(location).svgPath).not.toEqual(atDefault); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index 3b66e19d..36b6cab1 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -1,714 +1,618 @@ -/** - * Reactive glyph model. - * - * {@link Glyph} and {@link Contour} are reactive mirrors of Rust glyph data - * with per-contour signal granularity. Property getters auto-unwrap signals, - * so consumers read `glyph.contours`, `glyph.xAdvance`, `contour.points` - * as plain values. Inside a reactive context (computed/effect) the read - * auto-tracks the signal. - * - * All mutations go through {@link Glyph.apply}, which accepts either a full - * {@link GlyphSnapshot} (structural edits, undo/redo) or a - * {@link NodePositionUpdateList} (drag hot path). - */ - import type { - GlyphSnapshot, - ContourSnapshot, + AnchorId, ContourId, + GlyphName, + GlyphState, + GlyphStructure, + GlyphStructureChange, + GlyphValueChange, + GlyphVariationData, PointId, - AnchorId, - Point, - Anchor, - Point2D, + PointType, + Source, + Unicode, } from "@shift/types"; +import type { GlyphHandle } from "@shift/bridge"; import { - signal, - computed, batch, - type WritableSignal, + computed, + signal, type ComputedSignal, type Signal, -} from "@/lib/reactive/signal"; + type WritableSignal, +} from "@/lib/signals/signal"; +import { interpolate, normalize } from "@/lib/interpolation/interpolate"; +import { axisLocationFromLocation, axisLocationsEqual } from "@/lib/variation/location"; +import type { AxisLocation } from "@/types/variation"; +import { Transform } from "@/lib/transform/Transform"; +import { Alignment } from "@/lib/transform/Alignment"; +import type { AlignmentType, DistributeType, ReflectAxis } from "@/types/transform"; +import { Bounds, Vec2, type Bounds as BoundsType, type Point2D } from "@shift/geo"; import { - Contours, - Glyphs, - parseContourSegments, - segmentToCurve, - type SegmentContourLike, - type PointWithNeighbors, -} from "@shift/font"; -import { Bounds, Curve, Vec2, type Bounds as BoundsType } from "@shift/geo"; -import type { NodePositionUpdate, NodePositionUpdateList } from "@/types/positionUpdate"; -import { Segment } from "@/lib/model/Segment"; - -export interface GlyphSidebearings { - readonly lsb: number | null; - readonly rsb: number | null; + Anchor, + Component, + Contour, + GlyphStateGeometry as GlyphGeometry, + Segment, + type GlyphPosition as SourcePosition, + type GlyphPositions as SourcePositions, + type GlyphPositionTarget as SourcePositionTarget, + type GlyphSidebearings, + type Point, +} from "@shift/glyph-state"; +import { GlyphOutline } from "./GlyphOutline"; +import { SourcePositionList } from "./SourcePositionList"; +import type { Font } from "./Font"; + +export interface PointEdit { + readonly x: number; + readonly y: number; + readonly pointType: PointType; + readonly smooth: boolean; } -export type GlyphChange = GlyphSnapshot | NodePositionUpdateList; +export { + GlyphGeometry, + type GlyphSidebearings, + type SourcePosition, + type SourcePositions, + type SourcePositionTarget, +}; + +interface GlyphEditState { + readonly structure: WritableSignal; + readonly values: WritableSignal; + readonly geometry: Signal; +} -import type { Canvas } from "@/lib/editor/rendering/Canvas"; -import type { NativeBridge } from "@/bridge"; -import type { PointEdit, PasteResult } from "@/types/engine"; -import type { ContourContent } from "@/lib/clipboard"; -import { Transform } from "@/lib/transform/Transform"; -import { Alignment } from "@/lib/transform/Alignment"; -import type { AlignmentType, DistributeType, ReflectAxis } from "@/types/transform"; -import type { PointPosition } from "@/lib/transform/PointPosition"; - -export class Contour { - readonly id: ContourId; - readonly #closed: WritableSignal; - readonly #points: WritableSignal; - readonly #path: ComputedSignal; - readonly #bounds: ComputedSignal; - - constructor(snapshot: ContourSnapshot) { - this.id = snapshot.id; - this.#closed = signal(snapshot.closed); - this.#points = signal(snapshot.points); - this.#path = computed(() => buildPath2D(this.#points.value, this.#closed.value)); - this.#bounds = computed(() => { - const pts = this.#points.value; - const isClosed = this.#closed.value; - if (pts.length < 2) return null; - const segments = parseContourSegments({ points: pts, closed: isClosed }); - if (segments.length === 0) return null; - return Bounds.unionAll(segments.map((s) => Curve.bounds(segmentToCurve(s)))); - }); - } +class GlyphEditSession { + readonly #font: Font; + readonly #handle: GlyphHandle; + readonly #source: Source; + readonly #state: GlyphEditState; - get closed(): boolean { - return this.#closed.value; + constructor(font: Font, handle: GlyphHandle, source: Source, state: GlyphEditState) { + this.#font = font; + this.#handle = handle; + this.#source = source; + this.#state = state; } - get points(): readonly Point[] { - return this.#points.value; + get geometry(): GlyphGeometry { + return this.#state.geometry.value; } - get path(): Path2D { - return this.#path.value; + setXAdvance(width: number): void { + this.#ensureActiveSession(); + this.#applyValueChange(this.#font.bridge.setXAdvance(width)); } - get bounds(): BoundsType | null { - return this.#bounds.value; + setPositions(updates: SourcePositions): void { + if (updates.length === 0) return; + this.#ensureActiveSession(); + this.#applyValueChange( + this.#font.bridge.setPositions(...GlyphGeometry.packPositionUpdates(updates)), + ); } - get firstPoint(): Point | null { - return this.#points.value[0] ?? null; + translateLayer(dx: number, dy: number): void { + this.#ensureActiveSession(); + this.#applyValueChange(this.#font.bridge.translateLayer(dx, dy)); } - get lastPoint(): Point | null { - const pts = this.#points.value; - return pts[pts.length - 1] ?? null; + previewPositions(updates: SourcePositions): void { + if (updates.length === 0) return; + const nextGeometry = this.#state.geometry.peek().withPositionUpdates(updates); + this.#state.values.set(nextGeometry.values); } - get isEmpty(): boolean { - return this.#points.value.length === 0; + addContour(): ContourId { + this.#ensureActiveSession(); + const change = this.#font.bridge.addContour(); + this.#applyStructureChange(change); + const contourId = change.changed.contourIds[0]; + if (!contourId) throw new Error("Bridge did not return a created contour ID"); + return contourId; + } + + addPoint(contourId: ContourId, edit: PointEdit): PointId { + this.#ensureActiveSession(); + const change = this.#font.bridge.addPoint( + contourId, + edit.x, + edit.y, + edit.pointType, + edit.smooth, + ); + this.#applyStructureChange(change); + const pointId = change.changed.pointIds[0]; + if (!pointId) throw new Error("Bridge did not return a created point ID"); + return pointId; } - /** @knipclassignore */ - *withNeighbors(): Generator { - yield* Contours.withNeighbors(this); + insertPointBefore(beforePointId: PointId, edit: PointEdit): PointId { + this.#ensureActiveSession(); + const change = this.#font.bridge.insertPointBefore( + beforePointId, + edit.x, + edit.y, + edit.pointType, + edit.smooth, + ); + this.#applyStructureChange(change); + const pointId = change.changed.pointIds[0]; + if (!pointId) throw new Error("Bridge did not return a created point ID"); + return pointId; } - *segments(): Generator { - yield* Segment.parse(this.#points.value, this.#closed.value); + openContour(contourId: ContourId): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.openContour(contourId)); } - /** - * Tight bounds for the subset of this contour's points in `ids`. - * Fully-selected segments contribute their bezier envelope; partially-selected - * segments contribute the raw points of their selected endpoints. - */ - selectionBounds(ids: ReadonlySet): BoundsType | null { - const parts: (BoundsType | null)[] = []; - - for (const segment of this.segments()) { - if (segment.pointIds.every((id) => ids.has(id))) { - parts.push(segment.bounds); - } - } + closeContour(contourId: ContourId): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.closeContour(contourId)); + } - parts.push(Bounds.fromPoints(this.#points.value.filter((p) => ids.has(p.id)))); + reverseContour(contourId: ContourId): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.reverseContour(contourId)); + } - return Bounds.unionAll(parts); + applyBooleanOp( + contourIdA: ContourId, + contourIdB: ContourId, + operation: "union" | "subtract" | "intersect" | "difference", + ): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.applyBooleanOp(contourIdA, contourIdB, operation)); } - canClose(position: Point2D, hitRadius: number): boolean { - return Contours.canClose(this, position, hitRadius); + removePoints(pointIds: readonly PointId[]): void { + if (pointIds.length === 0) return; + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.removePoints([...pointIds])); } - /** @internal Called by Glyph.apply for structural updates. */ - _update(snapshot: ContourSnapshot): void { - this.#closed.set(snapshot.closed); - this.#points.set(snapshot.points); + toggleSmooth(pointId: PointId): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.toggleSmooth(pointId)); } - /** @internal Called by Glyph.apply for position patching. */ - _setPoints(points: readonly Point[]): void { - this.#points.set(points); + restore(state: GlyphState): void { + this.#ensureActiveSession(); + this.#applyStructureChange(this.#font.bridge.restoreState(state.structure, state.values)); } -} -export class Glyph { - readonly name: string; - readonly unicode: number; - readonly #bridge: NativeBridge; - - readonly #contours: WritableSignal; - readonly #xAdvance: WritableSignal; - readonly #anchors: WritableSignal; - readonly #activeContourId: WritableSignal; - readonly #path: ComputedSignal; - readonly #bbox: ComputedSignal; - readonly #sidebearings: ComputedSignal; - - constructor(bridge: NativeBridge) { - this.#bridge = bridge; - const snapshot = bridge.getSnapshot(); - this.name = snapshot.name; - this.unicode = snapshot.unicode; - this.#contours = signal(snapshot.contours.map((c) => new Contour(c))); - this.#xAdvance = signal(snapshot.xAdvance); - this.#anchors = signal(snapshot.anchors); - this.#activeContourId = signal(snapshot.activeContourId); - - // Root contours only. Composite components (for canvas display, grid, - // text run) come from `font.glyph(name).componentContours()` at the - // drawing site — that path stays reactive to $variationLocation, which - // this editable model intentionally does not. - this.#path = computed(() => { - const p = new Path2D(); - for (const c of this.#contours.value) { - p.addPath(c.path); - } - return p; - }); + #ensureActiveSession(): void { + const bridge = this.#font.bridge; + if ( + bridge.getEditingGlyphName() === this.#handle.name && + bridge.getEditingSourceId() === this.#source.id + ) { + return; + } - this.#bbox = computed(() => { - const bounds = this.#contours.value - .map((c) => c.bounds) - .filter((b): b is BoundsType => b !== null); - if (bounds.length === 0) return null; - return Bounds.unionAll(bounds); - }); + if (bridge.hasEditSession()) { + bridge.endEditSession(); + } - // Point-based x-range — cheap, matches integer-rounded sidebar display. - // Avoids warming the bezier #bbox chain which transitively computes - // every contour's bezier bounds. Consumers use `useGlyphSidebearings` - // React hook to subscribe; not exposed as a `$sidebearings` signal to - // prevent accidental subscription-driven recomputation. - this.#sidebearings = computed(() => { - let minX = Infinity; - let maxX = -Infinity; - for (const contour of this.#contours.value) { - for (const p of contour.points) { - if (p.x < minX) minX = p.x; - if (p.x > maxX) maxX = p.x; - } - } - if (minX === Infinity) return { lsb: null, rsb: null }; - return { lsb: minX, rsb: this.#xAdvance.value - maxX }; - }); + bridge.startEditSession(this.#handle, this.#source.id); } - get contours(): readonly Contour[] { - return this.#contours.value; + #applyStructureChange(change: GlyphStructureChange): void { + batch(() => { + this.#state.structure.set(change.structure); + this.#state.values.set(change.values); + }); } - /** - * @knipclassignore — used by purpose-specific React hooks in `@/hooks/` - * Signal that fires once per structural/position change — use this to - * subscribe to "something about the glyph changed" without forcing the - * bounds / path / bbox computeds to eagerly recompute. Consumers pull - * derived values on demand after the signal fires. - */ - get $contours(): Signal { - return this.#contours; + #applyValueChange(change: GlyphValueChange): void { + this.#state.values.set(change.values); } +} - get xAdvance(): number { - return this.#xAdvance.value; - } +export class GlyphSource { + readonly source: Source; + readonly #edit: GlyphEditSession; - get anchors(): readonly Anchor[] { - return this.#anchors.value; + constructor(source: Source, edit: GlyphEditSession) { + this.source = source; + this.#edit = edit; } - /** @knipclassignore */ - get activeContourId(): ContourId | null { - return this.#activeContourId.value; + /** @knipclassignore — convenience alias for source identity. */ + get id() { + return this.source.id; } - get path(): Path2D { - return this.#path.value; + /** @knipclassignore — convenience alias for source identity. */ + get sourceId() { + return this.source.id; } - /** @knipclassignore */ - get bbox(): BoundsType | null { - return this.#bbox.value; + get geometry(): GlyphGeometry { + return this.#edit.geometry; } - /** - * @knipclassignore — used by Editor command path and `useGlyphSidebearings` - * Sidebearings (point-based x-range) — pull at read time; for React live - * display use `useGlyphSidebearings()`. - */ - get sidebearings(): GlyphSidebearings { - return this.#sidebearings.value; + get state(): GlyphState { + return { + structure: this.geometry.structure, + values: new Float64Array(this.geometry.values), + }; } - /** @knipclassignore — subscribed by `useGlyphXAdvance` hook */ - get $xAdvance(): Signal { - return this.#xAdvance; + get xAdvance(): number { + return this.geometry.xAdvance; } - /** @knipclassignore Fill the glyph's complete path using the theme's glyph fill color. */ - draw(canvas: Canvas): void { - canvas.fillPath(this.path, canvas.theme.glyph.fill); + get contours(): readonly Contour[] { + return this.geometry.contours; } - /** @knipclassignore Stroke the glyph's complete path using the theme's glyph stroke style. */ - drawOutline(canvas: Canvas): void { - canvas.strokePath(this.path, canvas.theme.glyph.stroke, canvas.theme.glyph.widthPx); + get anchors(): readonly Anchor[] { + return this.geometry.anchors; } - /** @knipclassignore */ - point(pointId: PointId) { - return Glyphs.findPoint(this, pointId); + /** @knipclassignore — public authored-source geometry API. */ + get components(): readonly Component[] { + return this.geometry.components; } - /** @knipclassignore */ - points(pointIds: readonly PointId[]): Point[] { - return Glyphs.findPoints(this, [...pointIds]); + get bounds(): BoundsType | null { + return this.geometry.bounds; } - /** @knipclassignore */ - contour(contourId: ContourId) { - return Glyphs.findContour(this, contourId); + /** @knipclassignore — public authored-source metrics API. */ + get sidebearings(): GlyphSidebearings { + return this.geometry.sidebearings; } - /** @knipclassignore */ get allPoints(): Point[] { - return Glyphs.getAllPoints(this); + return this.geometry.allPoints; } - /** @knipclassignore */ - *segments(): Generator<{ segment: Segment; contourId: ContourId }> { - for (const contour of this.#contours.value) { - for (const segment of contour.segments()) { - yield { segment, contourId: contour.id }; - } - } + point(pointId: PointId): Point | null { + return this.geometry.point(pointId); } - /** @knipclassignore */ - getPointAt(pos: Point2D, radius: number): Point | null { - return Glyphs.getPointAt(this, pos, radius); + points(pointIds: readonly PointId[]): Point[] { + return this.geometry.points(pointIds); } - /** @knipclassignore — callers migrating from bridge → glyph */ - addPoint(edit: PointEdit): PointId { - return this.#bridge.addPoint(edit); + /** @knipclassignore — public authored-source lookup API. */ + anchor(anchorId: AnchorId): Anchor | null { + return this.geometry.anchor(anchorId); } - /** @knipclassignore */ - addPointToContour(contourId: ContourId, edit: PointEdit): PointId { - return this.#bridge.addPointToContour(contourId, edit); + contour(contourId: ContourId): Contour | null { + return this.geometry.contour(contourId); } - /** @knipclassignore */ - insertPointBefore(beforePointId: PointId, edit: PointEdit): PointId { - return this.#bridge.insertPointBefore(beforePointId, edit); + positionsFor(targets: readonly SourcePositionTarget[]): SourcePosition[] { + return [...SourcePositionList.fromTargets(this.geometry, targets).positions]; } - /** @knipclassignore */ - movePoints(pointIds: PointId[], delta: Point2D): PointId[] { - return this.#bridge.movePoints(pointIds, delta); + setXAdvance(width: number): void { + this.#edit.setXAdvance(width); } - /** @knipclassignore */ - movePointTo(pointId: PointId, position: Point2D): void { - this.#bridge.movePointTo(pointId, position.x, position.y); + setPositions(updates: SourcePositions): void { + this.#edit.setPositions(updates); } - /** @knipclassignore */ - moveAnchors(anchorIds: AnchorId[], delta: Point2D): void { - this.#bridge.moveAnchors(anchorIds, delta); + translateLayer(dx: number, dy: number): void { + this.#edit.translateLayer(dx, dy); } - /** @knipclassignore */ - removePoints(pointIds: PointId[]): void { - this.#bridge.removePoints(pointIds); + previewPositions(updates: SourcePositions): void { + this.#edit.previewPositions(updates); } - /** @knipclassignore */ - toggleSmooth(pointId: PointId): void { - this.#bridge.toggleSmooth(pointId); + addContour(): ContourId { + return this.#edit.addContour(); } - /** @knipclassignore */ - addContour(): ContourId { - return this.#bridge.addContour(); + addPoint(contourId: ContourId, edit: PointEdit): PointId { + return this.#edit.addPoint(contourId, edit); } - /** @knipclassignore */ - closeContour(): void { - this.#bridge.closeContour(); + insertPointBefore(pointId: PointId, edit: PointEdit): PointId { + return this.#edit.insertPointBefore(pointId, edit); } - /** @knipclassignore */ openContour(contourId: ContourId): void { - this.#bridge.openContour(contourId); + this.#edit.openContour(contourId); + } + + closeContour(contourId: ContourId): void { + this.#edit.closeContour(contourId); } - /** @knipclassignore */ reverseContour(contourId: ContourId): void { - this.#bridge.reverseContour(contourId); + this.#edit.reverseContour(contourId); } - /** @knipclassignore */ - setXAdvance(width: number): void { - this.#bridge.setXAdvance(width); + applyBooleanOp( + contourIdA: ContourId, + contourIdB: ContourId, + operation: "union" | "subtract" | "intersect" | "difference", + ): void { + this.#edit.applyBooleanOp(contourIdA, contourIdB, operation); } - /** @internal High-throughput position write primitive used by domain verbs. */ - setNodePositions(updates: NodePositionUpdateList): void { - this.#bridge.setNodePositions(updates); + removePoints(pointIds: readonly PointId[]): void { + this.#edit.removePoints(pointIds); } - /** @knipclassignore */ - translate(pointIds: readonly PointId[], delta: Point2D): void { - if (pointIds.length === 0 || (delta.x === 0 && delta.y === 0)) return; + toggleSmooth(pointId: PointId): void { + this.#edit.toggleSmooth(pointId); + } - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; + movePointTo(pointId: PointId, position: Point2D): void { + this.setPositions([{ kind: "point", id: pointId, x: position.x, y: position.y }]); + } - this.#applyPointUpdates( - points.map((point) => { - const next = Vec2.add(point, delta); - return { node: { kind: "point", id: point.id }, x: next.x, y: next.y }; - }), - ); + movePoints(pointIds: readonly PointId[], delta: Point2D): void { + const positions = this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))); + const nextPositions = positions.map((position) => { + const next = Vec2.add(position, delta); + return { ...position, x: next.x, y: next.y }; + }); + + this.setPositions(nextPositions); + } + + translate(pointIds: readonly PointId[], delta: Point2D): void { + this.movePoints(pointIds, delta); } - /** @knipclassignore */ moveSelectionTo(pointIds: readonly PointId[], target: Point2D, anchor: Point2D): void { - if (pointIds.length === 0) return; + this.movePoints(pointIds, Vec2.sub(target, anchor)); + } - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; + rotate(pointIds: readonly PointId[], angle: number, origin: Point2D): void { + this.setPositions( + Transform.rotatePoints( + this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))), + angle, + origin, + ), + ); + } - const delta = Vec2.sub(target, anchor); + scale(pointIds: readonly PointId[], sx: number, sy: number, origin: Point2D): void { + this.setPositions( + Transform.scalePoints( + this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))), + sx, + sy, + origin, + ), + ); + } - this.#applyPointUpdates( - points.map((point) => { - const next = Vec2.add(point, delta); - return { node: { kind: "point", id: point.id }, x: next.x, y: next.y }; - }), + reflect(pointIds: readonly PointId[], axis: ReflectAxis, origin: Point2D): void { + this.setPositions( + Transform.reflectPoints( + this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))), + axis, + origin, + ), ); } - /** @knipclassignore */ - rotate(pointIds: readonly PointId[], angle: number, origin: Point2D): void { - if (pointIds.length === 0 || angle === 0) return; + align(pointIds: readonly PointId[], alignment: AlignmentType): void { + const points = this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))); + const bounds = Bounds.fromPoints(points); + if (!bounds) return; - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; + this.setPositions(Alignment.alignPoints(points, alignment, bounds)); + } - this.#applyPointPositions(Transform.rotatePoints(points, angle, origin)); + distribute(pointIds: readonly PointId[], type: DistributeType): void { + this.setPositions( + Alignment.distributePoints( + this.positionsFor(pointIds.map((id) => ({ kind: "point", id }))), + type, + ), + ); } - /** @knipclassignore */ - scale(pointIds: readonly PointId[], sx: number, sy: number, origin: Point2D): void { - if (pointIds.length === 0 || (sx === 1 && sy === 1)) return; + restore(state: GlyphState): void { + this.#edit.restore(state); + } +} - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; +export class GlyphInstance { + readonly location: AxisLocation; - this.#applyPointPositions(Transform.scalePoints(points, sx, sy, origin)); - } + readonly #glyph: Glyph; - /** @knipclassignore */ - reflect(pointIds: readonly PointId[], axis: ReflectAxis, origin: Point2D): void { - if (pointIds.length === 0) return; + constructor(glyph: Glyph, location: AxisLocation) { + this.#glyph = glyph; + this.location = location; + } - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; + get geometry(): GlyphGeometry { + return this.#glyph.geometryAt(this.location); + } - this.#applyPointPositions(Transform.reflectPoints(points, axis, origin)); + get outline(): GlyphOutline { + return this.#glyph.outlineAt(this.location); } +} - /** @knipclassignore */ - align(pointIds: readonly PointId[], alignment: AlignmentType): void { - if (pointIds.length === 0) return; +export class Glyph { + readonly handle: GlyphHandle; - const points = this.#resolvePointPositions(pointIds); - if (points.length === 0) return; + readonly #font: Font; + readonly #source: Source; - const bounds = Bounds.fromPoints(points); - if (!bounds) return; + readonly #structure: WritableSignal; + readonly #values: WritableSignal; + readonly #variationData: GlyphVariationData | null; - this.#applyPointPositions(Alignment.alignPoints(points, alignment, bounds)); - } + readonly #geometry: ComputedSignal; - /** @knipclassignore */ - distribute(pointIds: readonly PointId[], type: DistributeType): void { - if (pointIds.length < 3) return; + readonly #xAdvance: ComputedSignal; + readonly #edit: GlyphEditSession; + + constructor(font: Font, handle: GlyphHandle, source: Source, state: GlyphState) { + this.handle = handle; + this.#font = font; + this.#source = source; + + this.#structure = signal(state.structure); + this.#values = signal(state.values); + this.#variationData = state.variationData ?? null; - const points = this.#resolvePointPositions(pointIds); - if (points.length < 3) return; + this.#geometry = computed(() => new GlyphGeometry(this.#structure.value, this.#values.value)); - this.#applyPointPositions(Alignment.distributePoints(points, type)); + this.#xAdvance = computed(() => this.#geometry.value.xAdvance); + this.#edit = new GlyphEditSession(font, handle, source, { + structure: this.#structure, + values: this.#values, + geometry: this.#geometry, + }); } - /** @knipclassignore */ - setActiveContour(contourId: ContourId): void { - this.#bridge.setActiveContour(contourId); + get name(): GlyphName { + return this.handle.name; } - /** @knipclassignore */ - clearActiveContour(): void { - this.#bridge.clearActiveContour(); + get unicode(): Unicode | null { + return this.handle.unicode ?? null; } - /** @knipclassignore */ - restoreSnapshot(snapshot: GlyphSnapshot): void { - this.#bridge.restoreSnapshot(snapshot); + get xAdvance(): number { + return this.#xAdvance.value; } - /** @knipclassignore */ - translateLayer(dx: number, dy: number): void { - this.#bridge.translateLayer(dx, dy); + get contours(): readonly Contour[] { + return this.#geometry.value.contours; } - /** @knipclassignore */ - pasteContours(contours: ContourContent[], offsetX: number, offsetY: number): PasteResult { - return this.#bridge.pasteContours(contours, offsetX, offsetY); + get anchors(): readonly Anchor[] { + return this.#geometry.value.anchors; } - /** - * Apply a change to the glyph. Accepts either a full snapshot (structural - * edits, undo/redo) or position updates (drag hot path). The glyph picks - * the efficient internal path automatically. - */ - apply(change: GlyphChange): void { - if (isSnapshot(change)) { - this.#syncFromSnapshot(change); - } else { - this.#patchPositions(change); - } + /** @knipclassignore — public visible-geometry API. */ + get components(): readonly Component[] { + return this.#geometry.value.components; } - /** - * @knipclassignore — used by VariationPanel for live interpolation - * - * Apply interpolated values from variation math. - * - * `values` order MUST match `flatten()` in crates/shift-core/src/interpolation.rs: - * [xAdvance, p0.x, p0.y, p1.x, p1.y, ..., a0.x, a0.y, ...] - * - * In-place patch — reuses Point/Contour/Anchor identities, fires per-contour - * signals via a single batch. No struct allocation tree, no JSON parse, no - * NAPI hop on the hot path. - * - * Length-checked at runtime to catch drift between Rust's flatten() walk and - * this one. Round-trip-tested in interpolate.test.ts (parity test ensures - * the values themselves are correct). - */ - applyValues(values: Float64Array): void { - const contours = this.#contours.peek(); - const anchors = this.#anchors.peek(); - - let expected = 1; // xAdvance - for (const c of contours) expected += c.points.length * 2; - expected += anchors.length * 2; - - if (values.length !== expected) { - throw new Error( - `Glyph.applyValues: length mismatch — got ${values.length}, expected ${expected}. ` + - `flatten() in shift-core::interpolation may have drifted from this walk.`, - ); - } + get bounds(): BoundsType | null { + return this.#geometry.value.bounds; + } - batch(() => { - let i = 0; - this.#xAdvance.set(values[i++]); - - for (const contour of contours) { - contour._setPoints( - contour.points.map((pt) => { - const x = values[i++]; - const y = values[i++]; - return { ...pt, x, y }; - }), - ); - } - this.#contours.set([...contours]); - - this.#anchors.set( - anchors.map((a) => { - const x = values[i++]; - const y = values[i++]; - return { ...a, x, y }; - }), - ); - }); + /** @knipclassignore — public metrics API. */ + get sidebearings(): GlyphSidebearings { + return this.#geometry.value.sidebearings; } - /** Extract current reactive state as a plain snapshot (for undo, Rust sync). */ - toSnapshot(): GlyphSnapshot { - return { - name: this.name, - unicode: this.unicode, - xAdvance: this.#xAdvance.peek(), - contours: this.#contours.peek().map((c) => ({ - id: c.id, - points: [...c.points], - closed: c.closed, - })), - anchors: [...this.#anchors.peek()], - compositeContours: [], - activeContourId: this.#activeContourId.peek(), - }; + get allPoints(): Point[] { + return this.#geometry.value.allPoints; } - #resolvePointPositions(pointIds: readonly PointId[]): PointPosition[] { - return this.points(pointIds).map((point) => ({ - id: point.id, - x: point.x, - y: point.y, - })); + /** @knipclassignore — reactive contour API for UI consumers. */ + get $contours(): Signal { + return computed(() => this.contours); } - #applyPointPositions(points: readonly PointPosition[]): void { - this.#applyPointUpdates( - points.map((point) => ({ node: { kind: "point", id: point.id }, x: point.x, y: point.y })), - ); + get $xAdvance(): Signal { + return this.#xAdvance; } - #applyPointUpdates(updates: readonly NodePositionUpdate[]): void { - if (updates.length === 0) return; - this.setNodePositions(updates); + instanceAt(location: AxisLocation): GlyphInstance { + return new GlyphInstance(this, location); } - #syncFromSnapshot(snapshot: GlyphSnapshot): void { - batch(() => { - this.#xAdvance.set(snapshot.xAdvance); - this.#anchors.set(snapshot.anchors); - // snapshot.compositeContours intentionally ignored — see compositeContours getter. - this.#activeContourId.set(snapshot.activeContourId); - - const currentById = new Map(); - for (const c of this.#contours.peek()) { - currentById.set(c.id, c); - } + geometryAt(location: AxisLocation): GlyphGeometry { + const sourceLocation = axisLocationFromLocation(this.#source.location); + if (axisLocationsEqual(location, sourceLocation, [...this.#font.getAxes()])) { + return this.#geometry.value; + } + + const exactSource = this.#font.sourceAt(location); + if (exactSource) { + return this.#font.glyphSource(this.handle, exactSource)?.geometry ?? this.#geometry.value; + } + + if (!this.#variationData) { + return this.#geometry.value; + } - const updated: Contour[] = snapshot.contours.map((cs) => { - const existing = currentById.get(cs.id); - if (existing) { - existing._update(cs); - return existing; - } - return new Contour(cs); - }); + const values = interpolate(this.#variationData, normalize(location, [...this.#font.getAxes()])); - this.#contours.set(updated); + if (values.length === 0) { + return this.#geometry.value; + } + + return new GlyphGeometry(this.#structure.value, values); + } + + outline(location: Signal): GlyphOutline { + return new GlyphOutline(this, { + variationLocation: location, + glyph: (handle) => this.#font.glyph(handle), }); } - #patchPositions(updates: NodePositionUpdateList): void { - if (updates.length === 0) return; + outlineAt(location: AxisLocation): GlyphOutline { + return this.outline(signal(location)); + } - const pointMoves = new Map(); - const anchorMoves = new Map(); - - for (const u of updates) { - switch (u.node.kind) { - case "point": - pointMoves.set(u.node.id, u); - break; - case "anchor": - anchorMoves.set(u.node.id, u); - break; - } - } + isPrimarySource(source: Source): boolean { + return source.id === this.#source.id; + } - batch(() => { - if (pointMoves.size > 0) { - const contours = this.#contours.peek(); - for (const contour of contours) { - const pts = contour.points; - if (!pts.some((p) => pointMoves.has(p.id))) continue; - - contour._setPoints( - pts.map((pt) => { - const move = pointMoves.get(pt.id); - return move ? { ...pt, x: move.x, y: move.y } : pt; - }), - ); - } - this.#contours.set([...contours]); - } + /** @internal GlyphSource caching is owned by Font.glyphSource(). */ + createGlyphSource(source: Source, state?: GlyphState | null): GlyphSource | null { + if (!this.#font.source(source.id)) return null; + if (this.isPrimarySource(source)) return new GlyphSource(source, this.#edit); + if (!state) return null; - if (anchorMoves.size > 0) { - const current = this.#anchors.peek(); - if (current.some((a) => anchorMoves.has(a.id))) { - this.#anchors.set( - current.map((anchor) => { - const move = anchorMoves.get(anchor.id); - return move ? { ...anchor, x: move.x, y: move.y } : anchor; - }), - ); - } - } + const structure = signal(state.structure); + const values = signal(state.values); + const geometry = computed(() => new GlyphGeometry(structure.value, values.value)); + const edit = new GlyphEditSession(this.#font, this.handle, source, { + structure, + values, + geometry, }); + return new GlyphSource(source, edit); } -} -function isSnapshot(change: GlyphChange): change is GlyphSnapshot { - return !Array.isArray(change); -} + point(pointId: PointId): Point | null { + return this.#geometry.value.point(pointId); + } -function buildPath2D(points: SegmentContourLike["points"], closed: boolean): Path2D { - const path = new Path2D(); - if (points.length < 2) return path; - - const segments = parseContourSegments({ points, closed }); - const first = segments[0]; - if (!first) return path; - - path.moveTo(first.points.anchor1.x, first.points.anchor1.y); - - for (const segment of segments) { - switch (segment.type) { - case "line": - path.lineTo(segment.points.anchor2.x, segment.points.anchor2.y); - break; - case "quad": - path.quadraticCurveTo( - segment.points.control.x, - segment.points.control.y, - segment.points.anchor2.x, - segment.points.anchor2.y, - ); - break; - case "cubic": - path.bezierCurveTo( - segment.points.control1.x, - segment.points.control1.y, - segment.points.control2.x, - segment.points.control2.y, - segment.points.anchor2.x, - segment.points.anchor2.y, - ); - break; + points(pointIds: readonly PointId[]): Point[] { + return this.#geometry.value.points(pointIds); + } + + contour(contourId: ContourId): Contour | null { + return this.#geometry.value.contour(contourId); + } + + *segments(): Generator<{ segment: Segment; contourId: ContourId }> { + for (const contour of this.contours) { + for (const segment of contour.segments()) { + yield { segment, contourId: contour.id }; + } } } - if (closed) path.closePath(); - return path; + toState(): GlyphState { + const variationData = this.#variationData; + if (!variationData) return { structure: this.#structure.value, values: this.#values.value }; + + return { + structure: this.#structure.value, + values: this.#values.value, + variationData, + }; + } } diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts new file mode 100644 index 00000000..7b584be7 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts @@ -0,0 +1,223 @@ +import { Bounds, type Bounds as BoundsType, type Point2D } from "@shift/geo"; +import type { GlyphHandle } from "@shift/bridge"; +import type { Signal } from "@/lib/signals/signal"; +import { computed, type ComputedSignal } from "@/lib/signals/signal"; +import type { AxisLocation } from "@/types/variation"; +import type { Contour, Matrix } from "@shift/glyph-state"; +import type { Glyph } from "./Glyph"; + +export interface GlyphOutlineContext { + readonly variationLocation: Signal; + glyph(handle: GlyphHandle): Glyph | null; +} + +type OutlineCommand = + | { readonly kind: "move"; readonly to: Point2D } + | { readonly kind: "line"; readonly to: Point2D } + | { readonly kind: "quad"; readonly control: Point2D; readonly to: Point2D } + | { + readonly kind: "cubic"; + readonly control1: Point2D; + readonly control2: Point2D; + readonly to: Point2D; + } + | { readonly kind: "close" }; + +interface OutlineData { + readonly commands: readonly OutlineCommand[]; + readonly bounds: BoundsType | null; +} + +export class GlyphOutline { + readonly #data: ComputedSignal; + readonly #svgPath: ComputedSignal; + + constructor(glyph: Glyph, context: GlyphOutlineContext) { + this.#data = computed(() => collectGlyphOutline(glyph, context, identityMatrix(), new Set())); + this.#svgPath = computed(() => this.#data.value.commands.map(commandToSvg).join(" ")); + } + + get path(): Path2D { + const path = new Path2D(); + for (const command of this.#data.value.commands) { + switch (command.kind) { + case "move": + path.moveTo(command.to.x, command.to.y); + break; + case "line": + path.lineTo(command.to.x, command.to.y); + break; + case "quad": + path.quadraticCurveTo(command.control.x, command.control.y, command.to.x, command.to.y); + break; + case "cubic": + path.bezierCurveTo( + command.control1.x, + command.control1.y, + command.control2.x, + command.control2.y, + command.to.x, + command.to.y, + ); + break; + case "close": + path.closePath(); + break; + } + } + return path; + } + + get svgPath(): string { + return this.#svgPath.value; + } + + get $svgPath(): Signal { + return this.#svgPath; + } + + get bounds(): BoundsType | null { + return this.#data.value.bounds; + } + + /** @knipclassignore — public outline inspection API. */ + get isEmpty(): boolean { + return this.#data.value.commands.length === 0; + } +} + +function collectGlyphOutline( + glyph: Glyph, + context: GlyphOutlineContext, + matrix: Matrix, + stack: Set, +): OutlineData { + if (stack.has(glyph.name)) return { commands: [], bounds: null }; + stack.add(glyph.name); + + const geometry = glyph.geometryAt(context.variationLocation.value); + const commands: OutlineCommand[] = []; + const boundsPoints: Point2D[] = []; + + for (const contour of geometry.contours) { + appendContour(commands, boundsPoints, contour, matrix); + } + + for (const component of geometry.components) { + const componentGlyph = context.glyph({ name: component.baseGlyphName }); + if (!componentGlyph) continue; + + const child = collectGlyphOutline( + componentGlyph, + context, + composeMatrices(matrix, component.matrix), + stack, + ); + commands.push(...child.commands); + if (child.bounds) { + boundsPoints.push(child.bounds.min, child.bounds.max); + } + } + + stack.delete(glyph.name); + return { commands, bounds: Bounds.fromPoints(boundsPoints) }; +} + +function appendContour( + commands: OutlineCommand[], + boundsPoints: Point2D[], + contour: Contour, + matrix: Matrix, +): void { + const segments = contour.segments(); + const first = segments[0]; + if (!first) return; + + const start = transformPoint(matrix, first.anchor1); + commands.push({ kind: "move", to: start }); + boundsPoints.push(start); + + for (const segment of segments) { + switch (segment.raw.type) { + case "line": { + const to = transformPoint(matrix, segment.raw.points.anchor2); + commands.push({ kind: "line", to }); + boundsPoints.push(to); + break; + } + case "quad": { + const control = transformPoint(matrix, segment.raw.points.control); + const to = transformPoint(matrix, segment.raw.points.anchor2); + commands.push({ kind: "quad", control, to }); + boundsPoints.push(control, to); + break; + } + case "cubic": { + const control1 = transformPoint(matrix, segment.raw.points.control1); + const control2 = transformPoint(matrix, segment.raw.points.control2); + const to = transformPoint(matrix, segment.raw.points.anchor2); + commands.push({ kind: "cubic", control1, control2, to }); + boundsPoints.push(control1, control2, to); + break; + } + } + } + + if (contour.closed) commands.push({ kind: "close" }); +} + +function commandToSvg(command: OutlineCommand): string { + switch (command.kind) { + case "move": + return `M ${formatNumber(command.to.x)} ${formatNumber(command.to.y)}`; + case "line": + return `L ${formatNumber(command.to.x)} ${formatNumber(command.to.y)}`; + case "quad": + return [ + "Q", + formatNumber(command.control.x), + formatNumber(command.control.y), + formatNumber(command.to.x), + formatNumber(command.to.y), + ].join(" "); + case "cubic": + return [ + "C", + formatNumber(command.control1.x), + formatNumber(command.control1.y), + formatNumber(command.control2.x), + formatNumber(command.control2.y), + formatNumber(command.to.x), + formatNumber(command.to.y), + ].join(" "); + case "close": + return "Z"; + } +} + +function transformPoint(matrix: Matrix, point: Point2D): Point2D { + return { + x: matrix.xx * point.x + matrix.yx * point.y + matrix.dx, + y: matrix.xy * point.x + matrix.yy * point.y + matrix.dy, + }; +} + +function composeMatrices(parent: Matrix, child: Matrix): Matrix { + return { + xx: parent.xx * child.xx + parent.yx * child.xy, + yx: parent.xx * child.yx + parent.yx * child.yy, + dx: parent.xx * child.dx + parent.yx * child.dy + parent.dx, + xy: parent.xy * child.xx + parent.yy * child.xy, + yy: parent.xy * child.yx + parent.yy * child.yy, + dy: parent.xy * child.dx + parent.yy * child.dy + parent.dy, + }; +} + +function identityMatrix(): Matrix { + return { xx: 1, xy: 0, yx: 0, yy: 1, dx: 0, dy: 0 }; +} + +function formatNumber(value: number): string { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return Object.is(rounded, -0) ? "0" : `${rounded}`; +} diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts deleted file mode 100644 index cbd712be..00000000 --- a/apps/desktop/src/renderer/src/lib/model/GlyphView.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { effect } from "@/lib/reactive"; -import { Font } from "./Font"; -import { TestEditor, createBridge, MUTATORSANS_DESIGNSPACE } from "@/testing"; -import type { GlyphView } from "./GlyphView"; - -function loadMutatorSans(): Font { - const font = new Font(createBridge()); - font.load(MUTATORSANS_DESIGNSPACE); - return font; -} - -function flattenComponentCoords(view: GlyphView): number[] { - const out: number[] = []; - for (const block of view.componentContours()) { - for (const seg of block.segments) out.push(...seg.points); - } - return out; -} - -function locationOverride(font: Font, override: Record) { - const out = { ...override }; - for (const axis of font.getAxes()) { - if (out[axis.tag] === undefined) out[axis.tag] = axis.default; - } - return out; -} - -describe("GlyphView — variation interpolation", () => { - it("composite glyph svgPath changes when the variation location moves", () => { - const font = loadMutatorSans(); - // Aacute = A + acute (pure composite in MutatorSans). - const aacute = font.glyph("Aacute"); - expect(aacute).not.toBeNull(); - - let lastSvg = ""; - const sub = effect(() => { - lastSvg = aacute!.$svgPath.value; - }); - - const atDefault = lastSvg; - expect(atDefault.length).toBeGreaterThan(0); - - const axes = font.getAxes(); - const bold = locationOverride(font, Object.fromEntries(axes.map((a) => [a.tag, a.maximum]))); - font.setVariationLocation(bold); - - expect(lastSvg).not.toBe(atDefault); - - sub.dispose(); - }); - - it("non-composite glyph also interpolates", () => { - const font = loadMutatorSans(); - const a = font.glyph("A"); - expect(a).not.toBeNull(); - - let lastSvg = ""; - const sub = effect(() => { - lastSvg = a!.$svgPath.value; - }); - - const atDefault = lastSvg; - const axes = font.getAxes(); - const bold = locationOverride(font, Object.fromEntries(axes.map((a) => [a.tag, a.maximum]))); - font.setVariationLocation(bold); - - expect(lastSvg).not.toBe(atDefault); - - sub.dispose(); - }); - - it("componentContours yields interpolated blocks (covers canvas component drawing)", () => { - // The canvas's renderToolScene reads view.componentContours() directly - // when drawing components of an active composite edit. This ensures the - // iterator yields different geometry across variation locations — i.e. - // composites in the canvas will redraw on slider scrub. - const font = loadMutatorSans(); - const aacute = font.glyph("Aacute")!; - - const atDefault = flattenComponentCoords(aacute); - expect(atDefault.length).toBeGreaterThan(0); - - const axes = font.getAxes(); - const bold = locationOverride(font, Object.fromEntries(axes.map((a) => [a.tag, a.maximum]))); - font.setVariationLocation(bold); - - const atBold = flattenComponentCoords(aacute); - expect(atBold).toHaveLength(atDefault.length); - expect(atBold).not.toEqual(atDefault); - }); - - it("editor.applyVariation re-interpolates a pure composite's component blocks", () => { - // Regression for the bug fixed in 22aa095 ("canvas interpolates composite - // components on slider scrub"). Pure composites have no own variationData - // so applyVariation's per-glyph interpolation no-ops — the slider must - // still flow through font.$variationLocation so the canvas redraw path - // (font.glyph(name).componentContours()) picks up new component geometry. - const editor = new TestEditor(); - editor.font.load(MUTATORSANS_DESIGNSPACE); - editor.open({ glyphName: "Aacute", unicode: 0x00c1 }); - - const view = editor.font.glyph("Aacute")!; - const atDefault = flattenComponentCoords(view); - expect(atDefault.length).toBeGreaterThan(0); - - const axes = editor.font.getAxes(); - const bold = locationOverride( - editor.font, - Object.fromEntries(axes.map((a) => [a.tag, a.maximum])), - ); - editor.applyVariation(bold); - - const atBold = flattenComponentCoords(view); - expect(atBold).not.toEqual(atDefault); - }); - - it("rootContours of a pure composite is empty", () => { - const font = loadMutatorSans(); - const aacute = font.glyph("Aacute")!; - const blocks = [...aacute.rootContours()]; - expect(blocks).toEqual([]); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts b/apps/desktop/src/renderer/src/lib/model/GlyphView.ts deleted file mode 100644 index b87ffdcc..00000000 --- a/apps/desktop/src/renderer/src/lib/model/GlyphView.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { - Axis, - AxisLocation, - Anchor, - Component, - DecomposedTransform, - GlyphGeometry, - GlyphVariationData, -} from "@shift/types"; - -import { computed, type ComputedSignal, type Signal } from "../reactive"; -import { interpolate, normalize } from "../interpolation/interpolate"; -import type { Font } from "./Font"; -import { Bounds } from "@shift/geo"; - -/** - * One curve segment from a glyph's contour at the current variation location. - * - * `points` is a flat list of x/y pairs. Length depends on `kind`: - * - line: [x1, y1, x2, y2] - * - quad: [x1, y1, cx, cy, x2, y2] - * - cubic: [x1, y1, c1x, c1y, c2x, c2y, x2, y2] - * - * The flat layout means transforms apply with one loop and SVG emission is a - * straight readout — no per-segment shape gymnastics. - */ -export type Segment = { kind: "line" | "quad" | "cubic"; points: readonly number[] }; - -export type ContourBlock = { closed: boolean; segments: Segment[] }; - -/** - * Build a `Path2D` from one ContourBlock. Used by the canvas drawing layer - * when it consumes `componentContours()` directly (composites in editor). - */ -export function blockToPath2D(block: ContourBlock): Path2D { - const p = new Path2D(); - if (block.segments.length === 0) return p; - p.moveTo(block.segments[0].points[0], block.segments[0].points[1]); - for (const seg of block.segments) { - switch (seg.kind) { - case "line": - p.lineTo(seg.points[2], seg.points[3]); - break; - case "quad": - p.quadraticCurveTo(seg.points[2], seg.points[3], seg.points[4], seg.points[5]); - break; - case "cubic": - p.bezierCurveTo( - seg.points[2], - seg.points[3], - seg.points[4], - seg.points[5], - seg.points[6], - seg.points[7], - ); - break; - } - } - if (block.closed) p.closePath(); - return p; -} - -/** - * Reactive read-only view of a glyph at the current variation location. - * - * Auto-tracking via signals: read `glyph.svgPath` or `glyph.advance` inside a - * computed/effect and the consumer re-runs when the location signal moves. - * - * Composites recurse via `font.glyph(baseName)` at iteration time so the LRU - * stays the single bookkeeping mechanism — no refcounts, no pinned entries. - */ -export class GlyphView { - readonly name: string; - readonly #geometry: GlyphGeometry; - readonly #components: ReadonlyArray; - readonly #font: Font; - readonly #values: ComputedSignal; - readonly #svgPath: ComputedSignal; - readonly #path: ComputedSignal; - readonly #advance: ComputedSignal; - readonly #bounds: ComputedSignal; - - constructor( - name: string, - geometry: GlyphGeometry, - variationData: GlyphVariationData | null, - components: ReadonlyArray, - axes: Axis[], - $location: Signal, - font: Font, - ) { - this.name = name; - this.#geometry = geometry; - this.#components = components; - this.#font = font; - this.#values = computed(() => - variationData - ? interpolate(variationData, normalize($location.value, axes)) - : flattenGeometry(geometry), - ); - // Direct $location edge so the chain survives LRU eviction of any base - // glyph this view recurses through. Without it, when a base is evicted - // mid-session its dispose severs the only path from $location back to - // this composite — slider scrubs would silently stop invalidating it. - this.#svgPath = computed(() => { - $location.value; - return buildSvgPath(this.contours()); - }); - // Cached Path2D — re-built only when $svgPath fires (variation scrub or - // geometry mutation). Constructing a Path2D from an SVG string is the - // hot path during text-run draws; this turns per-frame allocation - // into a one-time cost per variation tick. - this.#path = computed(() => new Path2D(this.#svgPath.value)); - this.#advance = computed(() => this.#values.value[0]); - // Cached bbox — `font.getBbox` is a NAPI call into Rust; positioner asks - // every layout rebuild, so without this it dominates the variation-scrub - // hot path. Wrapped in a computed (with no tracked deps today) so it - // upgrades cleanly when variation-aware bounds land. - this.#bounds = computed(() => this.#font.getBbox(this.name) ?? null); - } - - get advance(): number { - return this.#advance.value; - } - - get $svgPath(): Signal { - return this.#svgPath; - } - - get $path(): Signal { - return this.#path; - } - - get $advance(): Signal { - return this.#advance; - } - - get bounds(): Bounds | null { - return this.#bounds.value; - } - - get anchors(): readonly Anchor[] { - const v = this.#values.value; - let cursor = 1; - for (const contour of this.#geometry.contours) { - cursor += contour.points.length * 2; - } - - return this.#geometry.anchors.map((anchor, index) => ({ - ...anchor, - x: v[cursor + index * 2], - y: v[cursor + index * 2 + 1], - })); - } - - /** - * Root contours owned directly by this glyph at the current location. - * Empty for pure composites. - */ - *rootContours(): Iterable { - const v = this.#values.value; - let cursor = 1; - for (const contour of this.#geometry.contours) { - const segments = classifySegments(contour.points, contour.closed, v, cursor); - cursor += contour.points.length * 2; - if (segments.length > 0) yield { closed: contour.closed, segments }; - } - } - - /** - * Component contours, recursed through `font.glyph(baseName).contours()` - * with the component transform applied. The caller passes a `visited` set - * so cycle-guarding works across the whole walk; pass a fresh set when - * called from outside `*contours()`. - */ - *componentContours(visited: Set = new Set([this.name])): Iterable { - for (const comp of this.#components) { - const base = this.#font.glyph(comp.baseGlyphName); - if (!base) continue; - const matrix = decomposedToMatrix(comp.transform); - for (const block of base.contours(visited)) { - yield { - closed: block.closed, - segments: block.segments.map((s) => transformSegment(s, matrix)), - }; - } - } - } - - /** - * Root + component contours. Used by grid and text-run consumers; the - * canvas combines `editable.path` (mutable root) with `componentContours` - * directly so it never goes through this method. - */ - *contours(visited: Set = new Set()): Iterable { - if (visited.has(this.name)) return; - visited.add(this.name); - try { - yield* this.rootContours(); - yield* this.componentContours(visited); - } finally { - visited.delete(this.name); - } - } - - /** Sever dependency edges so this view and its computeds can be GC'd. */ - dispose(): void { - this.#values.dispose(); - this.#svgPath.dispose(); - this.#advance.dispose(); - this.#bounds.dispose(); - } -} - -function flattenGeometry(g: GlyphGeometry): Float64Array { - let len = 1; - for (const c of g.contours) len += c.points.length * 2; - len += g.anchors.length * 2; - const out = new Float64Array(len); - out[0] = g.xAdvance; - let i = 1; - for (const c of g.contours) { - for (const p of c.points) { - out[i++] = p.x; - out[i++] = p.y; - } - } - for (const a of g.anchors) { - out[i++] = a.x; - out[i++] = a.y; - } - return out; -} - -/** - * Walk a contour's point types and emit line/quad/cubic segments with x/y - * read from the flat values array. Mirrors the Rust `CurveSegmentIter` and - * the JS `parseContourSegments` classifier — but without rebuilding point - * objects, since coords come from `values` and types come from `points`. - */ -function classifySegments( - points: GlyphGeometry["contours"][number]["points"], - closed: boolean, - values: Float64Array, - cursor: number, -): Segment[] { - const n = points.length; - if (n < 2) return []; - - const segments: Segment[] = []; - const limit = closed ? n : n - 1; - let i = 0; - - const ptType = (k: number) => points[k % n].pointType; - const x = (k: number) => values[cursor + (k % n) * 2]; - const y = (k: number) => values[cursor + (k % n) * 2 + 1]; - - while (i < limit) { - const a = ptType(i); - const b = ptType(i + 1); - - if (a === "onCurve" && b === "onCurve") { - segments.push({ kind: "line", points: [x(i), y(i), x(i + 1), y(i + 1)] }); - i += 1; - continue; - } - - if (a === "onCurve" && b === "offCurve") { - if (i + 2 >= (closed ? i + n : n)) break; - const c = ptType(i + 2); - - if (c === "onCurve") { - segments.push({ - kind: "quad", - points: [x(i), y(i), x(i + 1), y(i + 1), x(i + 2), y(i + 2)], - }); - i += 2; - continue; - } - - if (c === "offCurve") { - if (i + 3 >= (closed ? i + n : n)) break; - segments.push({ - kind: "cubic", - points: [x(i), y(i), x(i + 1), y(i + 1), x(i + 2), y(i + 2), x(i + 3), y(i + 3)], - }); - i += 3; - continue; - } - } - - i += 1; - } - - return segments; -} - -function buildSvgPath(blocks: Iterable): string { - const parts: string[] = []; - for (const { closed, segments } of blocks) { - if (segments.length === 0) continue; - const out: string[] = []; - const first = segments[0]; - out.push(`M ${first.points[0]} ${first.points[1]}`); - - for (const seg of segments) { - switch (seg.kind) { - case "line": - out.push(`L ${seg.points[2]} ${seg.points[3]}`); - break; - case "quad": - out.push(`Q ${seg.points[2]} ${seg.points[3]} ${seg.points[4]} ${seg.points[5]}`); - break; - case "cubic": - out.push( - `C ${seg.points[2]} ${seg.points[3]} ${seg.points[4]} ${seg.points[5]} ${seg.points[6]} ${seg.points[7]}`, - ); - break; - } - } - - if (closed) out.push("Z"); - parts.push(out.join(" ")); - } - return parts.join(" "); -} - -type Matrix = { xx: number; xy: number; yx: number; yy: number; dx: number; dy: number }; - -/** - * Port of `shift_ir::component::DecomposedTransform::to_matrix`. Order: - * translate to center → scale → skew → rotate → translate back → translate. - */ -function decomposedToMatrix(t: DecomposedTransform): Matrix { - const cosR = Math.cos((t.rotation * Math.PI) / 180); - const sinR = Math.sin((t.rotation * Math.PI) / 180); - const tanSx = Math.tan((t.skewX * Math.PI) / 180); - const tanSy = Math.tan((t.skewY * Math.PI) / 180); - - const xx = t.scaleX * cosR + t.scaleY * tanSx * sinR; - const xy = t.scaleX * sinR - t.scaleY * tanSx * cosR; - const yx = t.scaleY * -sinR + t.scaleX * tanSy * cosR; - const yy = t.scaleY * cosR + t.scaleX * tanSy * sinR; - - const dx = t.translateX + t.tCenterX - (xx * t.tCenterX + yx * t.tCenterY); - const dy = t.translateY + t.tCenterY - (xy * t.tCenterX + yy * t.tCenterY); - - return { xx, xy, yx, yy, dx, dy }; -} - -function transformSegment(seg: Segment, m: Matrix): Segment { - const out: number[] = []; - for (let i = 0; i < seg.points.length; i += 2) { - const x = seg.points[i]; - const y = seg.points[i + 1]; - out.push(m.xx * x + m.yx * y + m.dx, m.xy * x + m.yy * y + m.dy); - } - return { kind: seg.kind, points: out }; -} diff --git a/apps/desktop/src/renderer/src/lib/model/SourcePositionList.ts b/apps/desktop/src/renderer/src/lib/model/SourcePositionList.ts new file mode 100644 index 00000000..40771bdf --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/SourcePositionList.ts @@ -0,0 +1,110 @@ +import { Vec2, type Point2D } from "@shift/geo"; +import type { AnchorId, PointId } from "@shift/types"; +import { Transform } from "@/lib/transform/Transform"; +import type { GlyphGeometry, SourcePosition, SourcePositions, SourcePositionTarget } from "./Glyph"; + +export interface SourcePositionSubject { + readonly points?: readonly PointId[]; + readonly anchors?: readonly AnchorId[]; +} + +export class SourcePositionList { + readonly positions: SourcePositions; + + private constructor(positions: SourcePositions) { + this.positions = [...positions]; + } + + /** @knipclassignore — convenience constructor for draft callers. */ + static empty(): SourcePositionList { + return new SourcePositionList([]); + } + + static fromPositions(positions: SourcePositions): SourcePositionList { + return new SourcePositionList(positions); + } + + static fromSubject(geometry: GlyphGeometry, subject: SourcePositionSubject): SourcePositionList { + return SourcePositionList.fromTargets(geometry, SourcePositionList.targetsFromSubject(subject)); + } + + static fromTargets( + geometry: GlyphGeometry, + targets: readonly SourcePositionTarget[], + ): SourcePositionList { + const positions: SourcePosition[] = []; + + for (const target of targets) { + switch (target.kind) { + case "point": { + const point = geometry.point(target.id); + if (point) positions.push({ kind: "point", id: point.id, x: point.x, y: point.y }); + break; + } + case "anchor": { + const anchor = geometry.anchor(target.id); + if (anchor) positions.push({ kind: "anchor", id: anchor.id, x: anchor.x, y: anchor.y }); + break; + } + } + } + + return new SourcePositionList(positions); + } + + static targetsFromSubject(subject: SourcePositionSubject): SourcePositionTarget[] { + const targets: SourcePositionTarget[] = []; + if (subject.points) { + targets.push(...subject.points.map((id) => ({ kind: "point" as const, id }))); + } + if (subject.anchors) { + targets.push(...subject.anchors.map((id) => ({ kind: "anchor" as const, id }))); + } + + return targets; + } + + /** @knipclassignore — inverse projection for command/draft callers. */ + get targets(): readonly SourcePositionTarget[] { + return this.positions.map((position) => { + switch (position.kind) { + case "point": + return { kind: "point", id: position.id }; + case "anchor": + return { kind: "anchor", id: position.id }; + } + }); + } + + includeFromGeometry(geometry: GlyphGeometry, positions: SourcePositions): SourcePositionList { + const known = new Set(this.positions.map(positionKey)); + const missing = positions.filter((position) => !known.has(positionKey(position))); + if (missing.length === 0) return this; + + return new SourcePositionList([ + ...this.positions, + ...SourcePositionList.fromTargets(geometry, missing).positions, + ]); + } + + translate(delta: Point2D): SourcePositionList { + return new SourcePositionList( + this.positions.map((position) => { + const next = Vec2.add(position, delta); + return { ...position, x: next.x, y: next.y }; + }), + ); + } + + rotate(angle: number, origin: Point2D): SourcePositionList { + return new SourcePositionList(Transform.rotatePoints(this.positions, angle, origin)); + } + + scale(sx: number, sy: number, origin: Point2D): SourcePositionList { + return new SourcePositionList(Transform.scalePoints(this.positions, sx, sy, origin)); + } +} + +function positionKey(position: SourcePositionTarget): string { + return `${position.kind}:${position.id}`; +} diff --git a/apps/desktop/src/renderer/src/lib/model/glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/glyph.test.ts deleted file mode 100644 index fe0056c5..00000000 --- a/apps/desktop/src/renderer/src/lib/model/glyph.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { Glyph } from "./Glyph"; -import { asContourId, asPointId, asAnchorId } from "@shift/types"; -import { effect } from "@/lib/reactive/signal"; -import { createBridge } from "@/testing"; -import type { NativeBridge } from "@/bridge"; - -let bridge: NativeBridge; -let glyph: Glyph; - -beforeEach(() => { - bridge = createBridge(); - bridge.startEditSession({ glyphName: "A", unicode: 65 }); - glyph = bridge.$glyph.peek()!; -}); - -describe("Glyph", () => { - it("constructs with correct name and unicode", () => { - expect(glyph.name).toBe("A"); - expect(glyph.unicode).toBe(65); - }); - - it("exposes contours as reactive getters", () => { - expect(Array.isArray(glyph.contours)).toBe(true); - }); - - it("computes whole-glyph path", () => { - expect(glyph.path).toBeInstanceOf(Path2D); - }); - - describe("toSnapshot", () => { - it("round-trips correctly", () => { - const snapshot = glyph.toSnapshot(); - - expect(snapshot.name).toBe("A"); - expect(snapshot.unicode).toBe(65); - }); - }); - - describe("apply with snapshot", () => { - it("updates scalar fields", () => { - const snapshot = glyph.toSnapshot(); - glyph.apply({ ...snapshot, xAdvance: 600 }); - - expect(glyph.xAdvance).toBe(600); - }); - - it("reuses existing contour instances when IDs match", () => { - const snapshot = glyph.toSnapshot(); - if (snapshot.contours.length === 0) return; - - const contourBefore = glyph.contours[0]; - glyph.apply({ - ...snapshot, - contours: snapshot.contours.map((c) => ({ - ...c, - points: c.points.map((p) => ({ ...p, x: p.x + 10 })), - })), - }); - const contourAfter = glyph.contours[0]; - - expect(contourAfter).toBe(contourBefore); - }); - - it("creates new contour instances for new IDs", () => { - const snapshot = glyph.toSnapshot(); - const newContour = { - id: asContourId("new-contour"), - closed: true, - points: [ - { id: asPointId("np1"), x: 0, y: 0, pointType: "onCurve" as const, smooth: false }, - { id: asPointId("np2"), x: 50, y: 0, pointType: "onCurve" as const, smooth: false }, - { id: asPointId("np3"), x: 50, y: 50, pointType: "onCurve" as const, smooth: false }, - ], - }; - - glyph.apply({ ...snapshot, contours: [...snapshot.contours, newContour] }); - - expect(glyph.contours.length).toBe(snapshot.contours.length + 1); - }); - }); - - describe("apply with position updates", () => { - it("patches point positions without recreating contours", () => { - const snapshot = glyph.toSnapshot(); - if (snapshot.contours.length === 0 || snapshot.contours[0]!.points.length === 0) return; - - const pointId = snapshot.contours[0]!.points[0]!.id; - const contourBefore = glyph.contours[0]; - - glyph.apply([{ node: { kind: "point", id: pointId }, x: 999, y: 888 }]); - - expect(glyph.contours[0]).toBe(contourBefore); - const point = glyph.contours[0]?.points.find((p) => p.id === pointId); - expect(point?.x).toBe(999); - expect(point?.y).toBe(888); - }); - - it("patches anchor positions", () => { - const snapshot = glyph.toSnapshot(); - const anchorId = asAnchorId("test-anchor"); - glyph.apply({ - ...snapshot, - anchors: [{ id: anchorId, name: "top", x: 100, y: 200 }], - }); - - glyph.apply([{ node: { kind: "anchor", id: anchorId }, x: 300, y: 400 }]); - - expect(glyph.anchors[0]?.x).toBe(300); - expect(glyph.anchors[0]?.y).toBe(400); - }); - }); - - describe("reactivity", () => { - it("triggers effects when contour points change", () => { - const snapshot = glyph.toSnapshot(); - if (snapshot.contours.length === 0 || snapshot.contours[0]!.points.length === 0) return; - - let count = 0; - const dispose = effect(() => { - const _pts = glyph.contours[0]?.points; - count++; - }); - - const pointId = snapshot.contours[0]!.points[0]!.id; - glyph.apply([{ node: { kind: "point", id: pointId }, x: 1, y: 1 }]); - - expect(count).toBeGreaterThan(1); - dispose(); - }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md similarity index 94% rename from apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md rename to apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md index fd2bfc7d..9308958b 100644 --- a/apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md @@ -90,7 +90,7 @@ Pass `{ equals: () => false }` as the second argument to `signal()`. This is use - **`peek()` inside a computed breaks reactivity.** If a computed reads a signal via `.peek()`, it will not re-derive when that signal changes. This is intentional but easy to forget. - **Circular computed chains.** There is no cycle detection. A computed that reads itself (directly or indirectly) will hit the `#computing` re-entrancy guard and return the stale value. - **`useSignalState` must not be called conditionally.** It is a React hook and follows the rules of hooks. -- **Disposing a computed silently breaks chains that flowed through it.** If `A → B → C` (A is a source signal, B is a computed, C subscribes to B), and B is disposed, A no longer notifies C — but C does not know it has been orphaned. Pattern: when C's lifetime can outlive B's (e.g. B lives in an LRU and may be evicted), give C a direct edge to A in addition to the indirect one. The canonical case is `GlyphView.#svgPath`: it reads `$variationLocation.value` directly so a composite's reactive chain survives eviction of any base glyph it recurses through. +- **Disposing a computed silently breaks chains that flowed through it.** If `A -> B -> C` (A is a source signal, B is a computed, C subscribes to B), and B is disposed, A no longer notifies C -- but C does not know it has been orphaned. Pattern: when C's lifetime can outlive B's, give C a direct edge to A in addition to the indirect one. The canonical case is `GlyphOutline`: it reads the variation-location signal directly so composite outlines stay reactive through base glyph lookups. ## Verification diff --git a/apps/desktop/src/renderer/src/lib/reactive/index.ts b/apps/desktop/src/renderer/src/lib/signals/index.ts similarity index 100% rename from apps/desktop/src/renderer/src/lib/reactive/index.ts rename to apps/desktop/src/renderer/src/lib/signals/index.ts diff --git a/apps/desktop/src/renderer/src/lib/reactive/signal.test.ts b/apps/desktop/src/renderer/src/lib/signals/signal.test.ts similarity index 97% rename from apps/desktop/src/renderer/src/lib/reactive/signal.test.ts rename to apps/desktop/src/renderer/src/lib/signals/signal.test.ts index 39448f6b..ccb60d04 100644 --- a/apps/desktop/src/renderer/src/lib/reactive/signal.test.ts +++ b/apps/desktop/src/renderer/src/lib/signals/signal.test.ts @@ -209,11 +209,9 @@ describe("computed", () => { }); it("a direct edge keeps a consumer reactive after an intermediate is disposed", () => { - // This is the invariant `GlyphView.#svgPath` relies on. A composite's - // svgPath subscribes to $variationLocation through a base GlyphView's - // #values. If the LRU evicts the base and disposes its computed, the - // indirect chain is severed. To survive, the consumer must hold a - // direct edge to the source. + // This is the invariant `GlyphOutline` relies on. A composite outline + // subscribes directly to the variation-location signal so it stays + // reactive through base glyph lookups. const source = signal(0); const intermediate = computed(() => source.value * 2); diff --git a/apps/desktop/src/renderer/src/lib/reactive/signal.ts b/apps/desktop/src/renderer/src/lib/signals/signal.ts similarity index 100% rename from apps/desktop/src/renderer/src/lib/reactive/signal.ts rename to apps/desktop/src/renderer/src/lib/signals/signal.ts diff --git a/apps/desktop/src/renderer/src/lib/reactive/useSignal.ts b/apps/desktop/src/renderer/src/lib/signals/useSignal.ts similarity index 100% rename from apps/desktop/src/renderer/src/lib/reactive/useSignal.ts rename to apps/desktop/src/renderer/src/lib/signals/useSignal.ts diff --git a/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts b/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts index 1117fab8..a6430437 100644 --- a/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts +++ b/apps/desktop/src/renderer/src/lib/state/ShiftState.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { ShiftStateImpl, StateRegistry } from "./ShiftState"; -import { effect } from "@/lib/reactive/signal"; +import { effect } from "@/lib/signals/signal"; describe("ShiftState", () => { it("initializes with the factory value", () => { diff --git a/apps/desktop/src/renderer/src/lib/state/ShiftState.ts b/apps/desktop/src/renderer/src/lib/state/ShiftState.ts index 397ef54c..42eaff2f 100644 --- a/apps/desktop/src/renderer/src/lib/state/ShiftState.ts +++ b/apps/desktop/src/renderer/src/lib/state/ShiftState.ts @@ -9,7 +9,7 @@ * App-scoped states persist across documents (settings, preferences). * Document-scoped states persist per font file (text runs, viewport). */ -import { signal, type Signal, type WritableSignal } from "@/lib/reactive/signal"; +import { signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; export type StateScope = "app" | "document"; diff --git a/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts b/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts index c5d95422..87a903be 100644 --- a/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextBuffer.ts @@ -18,7 +18,7 @@ * cursorVisible (transient UI state). Those live in `TextInteraction` / * `TextRun`. */ -import { signal, batch, type WritableSignal, type Signal } from "@/lib/reactive/signal"; +import { signal, batch, type WritableSignal, type Signal } from "@/lib/signals/signal"; import type { Cell, TextCellId } from "./layout"; import { clamp } from "@/lib/utils/utils"; diff --git a/apps/desktop/src/renderer/src/lib/text/TextInteraction.ts b/apps/desktop/src/renderer/src/lib/text/TextInteraction.ts index cc25fc1e..7a0e4c06 100644 --- a/apps/desktop/src/renderer/src/lib/text/TextInteraction.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextInteraction.ts @@ -18,7 +18,7 @@ * will live in its own `CompositeInspection` class when that feature is * rebuilt — intentionally not folded in here. */ -import { signal, batch, type WritableSignal, type Signal } from "@/lib/reactive/signal"; +import { signal, batch, type WritableSignal, type Signal } from "@/lib/signals/signal"; import type { Cell } from "./layout"; export interface EditingTarget { diff --git a/apps/desktop/src/renderer/src/lib/text/TextRun.ts b/apps/desktop/src/renderer/src/lib/text/TextRun.ts index dc4bfefc..2237f553 100644 --- a/apps/desktop/src/renderer/src/lib/text/TextRun.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextRun.ts @@ -14,14 +14,15 @@ * previousLine so vertical motion preserves horizontal position across short * lines. goalX resets on horizontal nav, click, and edits. */ -import { signal, computed, type Signal, type ComputedSignal } from "@/lib/reactive/signal"; +import { signal, computed, type Signal, type ComputedSignal } from "@/lib/signals/signal"; import { TextBuffer } from "./TextBuffer"; import { TextInteraction } from "./TextInteraction"; import { Caret, glyphCell, TextLayout } from "./layout"; import type { Cell, GlyphAnchor, GlyphCell, Positioner, TextRunId } from "./layout"; import type { Font } from "@/lib/model/Font"; -import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; -import type { Point2D } from "@shift/types"; +import type { GlyphHandle } from "@shared/bridge/BridgeApi"; +import type { Point2D } from "@shift/geo"; +import type { AxisLocation } from "@/types/variation"; export interface SelectionRect { x: number; @@ -43,6 +44,7 @@ export class TextRun { readonly interaction: TextInteraction; readonly #font: Font; readonly #positioner: Positioner; + readonly #designLocation: Signal; readonly #$cursorVisible: Signal; readonly #$layout: ComputedSignal; @@ -51,12 +53,18 @@ export class TextRun { #goalX: number | null = null; - constructor(id: TextRunId, font: Font, positioner: Positioner) { + constructor( + id: TextRunId, + font: Font, + positioner: Positioner, + designLocation: Signal, + ) { this.id = id; this.buffer = new TextBuffer(); this.interaction = new TextInteraction(); this.#font = font; this.#positioner = positioner; + this.#designLocation = designLocation; this.#$cursorVisible = signal(false); this.#$layout = computed(() => { @@ -67,6 +75,7 @@ export class TextRun { origin: { x: this.buffer.originX, y: 0 }, font: this.#font, positioner: this.#positioner, + designLocation: this.#designLocation, }); }); @@ -217,7 +226,7 @@ export class TextRun { anchor, cell, glyph: { - glyphName: cell.glyphName, + name: cell.glyphName, ...(cell.codepoint !== null ? { unicode: cell.codepoint } : {}), }, editOrigin, @@ -225,7 +234,7 @@ export class TextRun { } setSingleGlyph(handle: GlyphHandle): GlyphAnchor { - const cell = glyphCell(handle.glyphName, handle.unicode ?? null); + const cell = glyphCell(handle.name, handle.unicode ?? null); this.buffer.restore({ cells: [cell], cursor: 1, diff --git a/apps/desktop/src/renderer/src/lib/text/TextRuns.ts b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts index 72b1b37b..4c5c4669 100644 --- a/apps/desktop/src/renderer/src/lib/text/TextRuns.ts +++ b/apps/desktop/src/renderer/src/lib/text/TextRuns.ts @@ -14,13 +14,14 @@ import { type Signal, type WritableSignal, type ComputedSignal, -} from "@/lib/reactive/signal"; +} from "@/lib/signals/signal"; import { TextRun } from "./TextRun"; import type { FocusedGlyph } from "./TextRun"; import type { Positioner } from "./layout"; import type { Font } from "@/lib/model/Font"; import type { TextBufferSnapshot } from "./TextBuffer"; import type { GlyphAnchor } from "./layout"; +import type { AxisLocation } from "@/types/variation"; const DEFAULT_RUN_KEY = "__default__"; export const EDITOR_RUN_ID = "__editor__"; @@ -35,14 +36,21 @@ export class TextRuns { readonly #$active: ComputedSignal; readonly #font: Font; readonly #positioner: Positioner; + readonly #designLocation: Signal; readonly #editorRun: TextRun; - constructor(font: Font, positioner: Positioner) { + constructor(font: Font, positioner: Positioner, designLocation: Signal) { this.#runs = new Map(); this.#$activeKey = signal(DEFAULT_RUN_KEY); this.#font = font; this.#positioner = positioner; - this.#editorRun = new TextRun(EDITOR_RUN_ID, this.#font, this.#positioner); + this.#designLocation = designLocation; + this.#editorRun = new TextRun( + EDITOR_RUN_ID, + this.#font, + this.#positioner, + this.#designLocation, + ); this.#$active = computed(() => this.#getOrCreate(this.#$activeKey.value)); } @@ -125,7 +133,7 @@ export class TextRuns { deserialize(persisted: Record): void { this.#runs.clear(); for (const [key, entry] of Object.entries(persisted)) { - const run = new TextRun(key, this.#font, this.#positioner); + const run = new TextRun(key, this.#font, this.#positioner, this.#designLocation); run.buffer.restore(entry.buffer); this.#runs.set(key, run); } @@ -146,7 +154,7 @@ export class TextRuns { #getOrCreate(key: string): TextRun { let run = this.#runs.get(key); if (run) return run; - run = new TextRun(key, this.#font, this.#positioner); + run = new TextRun(key, this.#font, this.#positioner, this.#designLocation); this.#runs.set(key, run); return run; } diff --git a/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts index 9e78c9e0..a0154d40 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Caret.test.ts @@ -41,7 +41,7 @@ describe("Caret", () => { // → 2 (start of line 2, before B) it("next steps through paragraph boundary", () => { const layout = makeLayout([glyph("A", 65), linebreakCell(), glyph("B", 66)], font); - const metrics = font.getMetrics(); + const metrics = font.metrics; const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); let c = Caret.atCluster(layout, 0); @@ -71,7 +71,7 @@ describe("Caret", () => { // line 1 cluster 2 = empty line 1 (caret sits at originX) it("position on empty trailing line lands at that line's baseline", () => { const layout = makeLayout([glyph("A", 65), linebreakCell()], font); - const metrics = font.getMetrics(); + const metrics = font.metrics; const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); const caret = Caret.atCluster(layout, 2); @@ -88,7 +88,7 @@ describe("Caret", () => { // line 2 cluster 2 it("position on empty line between two linebreaks lands on the middle line", () => { const layout = makeLayout([linebreakCell(), linebreakCell()], font); - const metrics = font.getMetrics(); + const metrics = font.metrics; const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); const caret = Caret.atCluster(layout, 1); diff --git a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts index 87dc6c6c..2fad899d 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.test.ts @@ -3,6 +3,7 @@ import type { Font } from "@/lib/model/Font"; import { Positioner } from "./Positioner"; import { glyphCell as glyph } from "./types"; import { loadTestFont, ltrRun } from "./testUtils"; +import { signal } from "@/lib/signals/signal"; describe("Positioner", () => { let font: Font; @@ -16,7 +17,7 @@ describe("Positioner", () => { const positioner = new Positioner(); const run = ltrRun([glyph("A", 65), glyph("B", 66), glyph("C", 67)]); - const positioned = positioner.position(run, font); + const positioned = positioner.position(run, font, signal(font.defaultLocation())); const sum = positioned.glyphs.reduce((s, g) => s + g.xAdvance, 0); expect(positioned.advance).toBe(sum); @@ -28,20 +29,25 @@ describe("Positioner", () => { const positioner = new Positioner(); const run = ltrRun([glyph("A", 65), glyph("B", 66)], /* clusterStart */ 7); - const positioned = positioner.position(run, font); + const positioned = positioner.position(run, font, signal(font.defaultLocation())); expect(positioned.glyphs.map((g) => g.cluster)).toEqual([7, 8]); }); - // Each positioned glyph carries the bounds the font reports. - it("bounds pass through from font.getBbox", () => { + // Each positioned glyph carries the bounds from the glyph outline. + it("bounds pass through from glyph outline", () => { const positioner = new Positioner(); const a = glyph("A", 65); const run = ltrRun([a]); - const positioned = positioner.position(run, font); + const positioned = positioner.position(run, font, signal(font.defaultLocation())); + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + const expectedBounds = font + .glyph({ name: "A" }, source) + ?.outline(signal(font.defaultLocation())).bounds; - expect(positioned.glyphs[0].bounds).toEqual(font.getBbox("A")); + expect(positioned.glyphs[0].bounds).toEqual(expectedBounds); expect(positioned.glyphs[0].cellIds).toEqual([a.id]); expect(positioned.glyphs[0].origin).toEqual({ x: 0, y: 0 }); }); @@ -51,7 +57,7 @@ describe("Positioner", () => { const positioner = new Positioner(); const run = ltrRun([glyph("nonexistent-glyph-xyz", 65)]); - const positioned = positioner.position(run, font); + const positioned = positioner.position(run, font, signal(font.defaultLocation())); expect(positioned.glyphs[0].xAdvance).toBe(0); expect(positioned.glyphs[0].bounds).toBeNull(); @@ -62,7 +68,7 @@ describe("Positioner", () => { const positioner = new Positioner(); const run = ltrRun([]); - const positioned = positioner.position(run, font); + const positioned = positioner.position(run, font, signal(font.defaultLocation())); expect(positioned.glyphs).toEqual([]); expect(positioned.advance).toBe(0); diff --git a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts index 844acd76..a3945ae5 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts @@ -1,7 +1,10 @@ import { displayAdvance, isNonSpacingGlyph } from "@/lib/utils/unicode"; import type { GlyphCell, PositionedRun, SegmentedRun } from "./types"; import { Font } from "@/lib/model/Font"; -import type { Point2D } from "@shift/types"; +import type { Signal } from "@/lib/signals/signal"; +import type { AxisLocation } from "@/types/variation"; +import type { Bounds, Point2D } from "@shift/geo"; +import type { Source } from "@shift/types"; /** * No-shape positioner — literal LTR advance walk, `cluster = clusterStart + i`. @@ -12,18 +15,29 @@ import type { Point2D } from "@shift/types"; * */ export class Positioner { - position(run: SegmentedRun, font: Font): PositionedRun { + position(run: SegmentedRun, font: Font, designLocation: Signal): PositionedRun { let totalAdvance = 0; + const glyphs: PositionedRun["glyphs"] = []; + const source = font.sourceAtOrDefault(designLocation.value); - const glyphs = run.glyphs.map((g, idx) => { - const glyph = font.glyph(g.glyphName); - const xAdvance = resolveAdvance(g, font); + for (const [idx, g] of run.glyphs.entries()) { + const handle = { name: g.glyphName }; + const glyph = font.glyph(handle); + let glyphName = g.glyphName; + let bounds: Bounds | null = null; + + if (glyph) { + glyphName = glyph.name; + bounds = glyph.outline(designLocation).bounds; + } + + const xAdvance = resolveAdvance(g, font, source); const origin = { x: totalAdvance, y: 0 }; - const offset = resolveGlyphOffset(g, font); + const offset = resolveGlyphOffset(g, font, source); totalAdvance += xAdvance; - return { - glyphName: glyph?.name ?? g.glyphName, + glyphs.push({ + glyphName, cellIds: [g.id], origin, xAdvance, @@ -31,27 +45,28 @@ export class Positioner { xOffset: offset.x, yOffset: offset.y, cluster: run.clusterStart + idx, - bounds: glyph?.bounds ?? null, - }; - }); + bounds, + }); + } return { ...run, glyphs, advance: totalAdvance }; } } /** Resolve a glyph cell to its display advance (handles invisibles, fallbacks). */ -export function resolveAdvance(cell: GlyphCell, font: Font): number { - const raw = font.glyph(cell.glyphName)?.advance ?? 0; +export function resolveAdvance(cell: GlyphCell, font: Font, source: Source | null): number { + const raw = source ? (font.glyphSource({ name: cell.glyphName }, source)?.xAdvance ?? 0) : 0; return displayAdvance(raw, cell.glyphName, cell.codepoint); } -export function resolveGlyphOffset(cell: GlyphCell, font: Font): Point2D { +export function resolveGlyphOffset(cell: GlyphCell, font: Font, source: Source | null): Point2D { if (!isNonSpacingGlyph(cell.glyphName, cell.codepoint)) return { x: 0, y: 0 }; + if (!source) return { x: 0, y: 0 }; - const glyph = font.glyph(cell.glyphName); + const glyph = font.glyphSource({ name: cell.glyphName }, source); if (!glyph) return { x: 0, y: 0 }; - const metrics = font.getMetrics(); + const metrics = font.metrics; const targetX = 300; const targetYForAnchorName = (anchorName: string): number => { switch (anchorName) { diff --git a/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts index c2826786..c30a4b00 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.test.ts @@ -43,7 +43,7 @@ describe("TextLayout", () => { // Second-line baseline math: y = origin.y - lineHeight. it("second-line baseline is one lineHeight below first", () => { const layout = makeLayout([glyph("A", 65), linebreakCell(), glyph("B", 66)], font); - const metrics = font.getMetrics(); + const metrics = font.metrics; const lineHeight = metrics.ascender - metrics.descender + (metrics.lineGap ?? 0); expect(layout.lines[0].y).toBe(0); @@ -54,8 +54,10 @@ describe("TextLayout", () => { // left half of B's advance box hits cluster=1, side="left". it("pointAt after hitTest recovers cluster's leading edge", () => { const layout = makeLayout([glyph("A", 65), glyph("B", 66)], font); - const aAdvance = font.glyph("A")?.advance ?? 0; - const bAdvance = font.glyph("B")?.advance ?? 0; + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + const aAdvance = font.glyphSource({ name: "A" }, source)?.xAdvance ?? 0; + const bAdvance = font.glyphSource({ name: "B" }, source)?.xAdvance ?? 0; const bLeftHalfX = aAdvance + bAdvance / 4; const hit = layout.hitTest({ x: bLeftHalfX, y: 0 }); @@ -68,7 +70,9 @@ describe("TextLayout", () => { const a = glyph("A", 65); const b = glyph("B", 66); const layout = makeLayout([a, b], font); - const aAdvance = font.glyph("A")?.advance ?? 0; + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + const aAdvance = font.glyphSource({ name: "A" }, source)?.xAdvance ?? 0; expect(layout.editOriginForCell(b.id)).toEqual({ x: aAdvance, y: 0 }); expect(layout.primaryGlyphForCell(b.id)?.cellIds).toEqual([b.id]); @@ -84,7 +88,9 @@ describe("TextLayout", () => { it("returns anchors with cell ids rather than cluster-only hits", () => { const b = glyph("B", 66); const layout = makeLayout([glyph("A", 65), b], font); - const aAdvance = font.glyph("A")?.advance ?? 0; + const source = font.defaultSource(); + if (!source) throw new Error("Expected source"); + const aAdvance = font.glyphSource({ name: "A" }, source)?.xAdvance ?? 0; expect(layout.anchorAtPoint("run-1", { x: aAdvance + 1, y: 0 })).toEqual({ runId: "run-1", diff --git a/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts index eb652e1e..5bbf3917 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/TextLayout.ts @@ -24,12 +24,15 @@ import type { } from "./types"; import type { Positioner } from "./Positioner"; import { Font } from "@/lib/model/Font"; +import type { Signal } from "@/lib/signals/signal"; +import type { AxisLocation } from "@/types/variation"; export interface TextLayoutParams { cells: readonly Cell[]; origin: Point2D; font: Font; positioner: Positioner; + designLocation: Signal; } interface AssembledLayout { @@ -49,15 +52,15 @@ export class TextLayout { readonly #cells: readonly Cell[]; constructor(params: TextLayoutParams) { - const { cells, origin, font, positioner } = params; + const { cells, origin, font, positioner, designLocation } = params; this.#cells = cells; - this.metrics = font.getMetrics(); + this.metrics = font.metrics; this.origin = origin; this.bufferLength = cells.length; // splitParagraphs → segmentRuns → position → assemble const paragraphs: PositionedParagraph[] = splitParagraphs(cells).map((p) => ({ - runs: segmentRuns(p).map((run) => positioner.position(run, font)), + runs: segmentRuns(p).map((run) => positioner.position(run, font, designLocation)), clusterStart: p.clusterStart, clusterEnd: p.clusterStart + p.glyphs.length + 1, })); diff --git a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts index 71c5ceec..dea95b9c 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -2,19 +2,21 @@ * Shared test fixtures for the layout module. * * Loads a real Font (MutatorSans) via the real Rust bridge — same pattern - * as `GlyphView.test.ts`. No fakes; tests assert against derived values - * read from the loaded font, so `expect(... .advance).toBe(font.glyph("A")?.advance)` - * not a hardcoded 600. + * as the model tests. No fakes; tests assert against derived values read from + * the loaded font, not hardcoded advances. */ import { Font } from "@/lib/model/Font"; -import { createBridge } from "@/testing/engine"; import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; import { TextLayout } from "./TextLayout"; import { Positioner } from "./Positioner"; import type { Cell, GlyphCell, SegmentedRun } from "./types"; +import { signal } from "@/lib/signals/signal"; +import { createBridge } from "@shift/bridge"; export function loadTestFont(): Font { - const font = new Font(createBridge()); + const bridge = createBridge(); + const font = new Font(bridge); + font.load(MUTATORSANS_DESIGNSPACE); return font; } @@ -29,5 +31,6 @@ export function makeLayout(cells: readonly Cell[], font: Font): TextLayout { origin: { x: 0, y: 0 }, font, positioner: new Positioner(), + designLocation: signal(font.defaultLocation()), }); } diff --git a/apps/desktop/src/renderer/src/lib/text/layout/types.ts b/apps/desktop/src/renderer/src/lib/text/layout/types.ts index 05c6edcb..31e10146 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/types.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/types.ts @@ -1,5 +1,5 @@ -import type { FontMetrics, Point2D } from "@shift/types"; -import type { Bounds } from "@shift/geo"; +import type { Bounds, Point2D } from "@shift/geo"; +import type { FontMetrics } from "@shift/types"; export type TextCellId = string; export type TextRunId = string; diff --git a/apps/desktop/src/renderer/src/lib/tools/core/BaseTool.ts b/apps/desktop/src/renderer/src/lib/tools/core/BaseTool.ts index 4d103921..6b2cc896 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/BaseTool.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/BaseTool.ts @@ -3,7 +3,7 @@ import type { ToolEvent } from "./GestureDetector"; import type { ToolName, ToolState } from "./createContext"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import type { Behavior, ToolContext } from "./Behavior"; -import { batch, computed, type ComputedSignal } from "../../reactive/signal"; +import { batch, computed, type ComputedSignal } from "../../signals/signal"; import type { CursorType } from "@/types/editor"; export type { ToolName, ToolState }; @@ -70,12 +70,13 @@ export abstract class BaseTool(name: string, fn: () => T): T { return this.editor.commands.withBatch(name, fn); } diff --git a/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts b/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts index df7ad3a7..a92d1462 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts @@ -29,7 +29,7 @@ export interface ToolContext { * state `S` and the {@link Editor}. Implement `canHandle` as a fast guard * (typically a state-type + event-type check) and `transition` as the pure * state computation. Use `onTransition` for post-transition side effects - * that need both the previous and next states (e.g. starting a snap session). + * that need both the previous and next states. */ export interface Behavior { // New explicit event handlers diff --git a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts index e492a674..625eb0f1 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts @@ -11,7 +11,7 @@ * * @module */ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Coordinates } from "@/types/coordinates"; /** Well-known key names that tools handle directly. */ diff --git a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts deleted file mode 100644 index c62d86f6..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.compliance.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { ToolEvent } from "./GestureDetector"; -import type { StateDiagram } from "./StateDiagram"; -import { transitionInDiagram } from "./StateDiagram"; -import { makeTestCoordinates, TestEditor } from "@/testing"; -import { Hand } from "../hand/Hand"; -import { Shape } from "../shape/Shape"; -import { Pen } from "../pen/Pen"; -import { Select } from "../select/Select"; -import { asPointId } from "@shift/types"; -import type { Handles } from "../pen/types"; - -const p = { x: 0, y: 0 }; -const coordsP = makeTestCoordinates(p); - -function makePointerMove(): ToolEvent { - return { type: "pointerMove", point: p, coords: coordsP }; -} -function makeClick(): ToolEvent { - return { type: "click", point: p, coords: coordsP, shiftKey: false, altKey: false }; -} -function makeDragStart(): ToolEvent { - return { - type: "dragStart", - point: p, - coords: coordsP, - screenPoint: p, - shiftKey: false, - altKey: false, - }; -} -function makeDrag(): ToolEvent { - return { - type: "drag", - point: p, - coords: coordsP, - screenPoint: p, - origin: p, - screenOrigin: p, - delta: p, - screenDelta: p, - shiftKey: false, - altKey: false, - }; -} -function makeDragEnd(): ToolEvent { - return { - type: "dragEnd", - point: p, - coords: coordsP, - screenPoint: p, - origin: p, - screenOrigin: p, - }; -} -function makeDragCancel(): ToolEvent { - return { type: "dragCancel" }; -} -function makeKeyDownEscape(): ToolEvent { - return { type: "keyDown", key: "Escape", shiftKey: false, altKey: false, metaKey: false }; -} -function makeSelectionChanged(): ToolEvent { - return { type: "selectionChanged" }; -} - -function assertResultInDiagram( - spec: StateDiagram, - result: { type: string }, - from: string, - eventType: string, -): void { - expect(spec.states).toContain(result.type); - if (result.type !== from) { - const diagramEvent = - eventType === "dragEnd" || eventType === "dragCancel" ? "dragEnd" : eventType; - expect(transitionInDiagram(spec, from, diagramEvent, result.type)).toBe(true); - } -} - -describe("State diagram compliance", () => { - let editor: TestEditor; - - beforeEach(() => { - editor = new TestEditor(); - }); - - describe("Hand", () => { - let hand: Hand; - let spec: StateDiagram; - - beforeEach(() => { - hand = new Hand(editor); - spec = Hand.stateSpec; - }); - - it("transition result is always in stateSpec.states and (from, event, to) in spec when state changes", () => { - const idle = { type: "idle" as const }; - const ready = { type: "ready" as const }; - const dragging = { - type: "dragging" as const, - screenStart: p, - startPan: p, - }; - - let result = hand.transition(idle, makePointerMove()); - assertResultInDiagram(spec, result, "idle", "pointerMove"); - - result = hand.transition(ready, makePointerMove()); - assertResultInDiagram(spec, result, "ready", "pointerMove"); - - result = hand.transition(ready, makeDragStart()); - assertResultInDiagram(spec, result, "ready", "dragStart"); - - result = hand.transition(dragging, makeDrag()); - assertResultInDiagram(spec, result, "dragging", "drag"); - - result = hand.transition(dragging, makeDragEnd()); - assertResultInDiagram(spec, result, "dragging", "dragEnd"); - - result = hand.transition(dragging, makeDragCancel()); - assertResultInDiagram(spec, result, "dragging", "dragCancel"); - }); - }); - - describe("Shape", () => { - let shape: Shape; - let spec: StateDiagram; - - beforeEach(() => { - shape = new Shape(editor); - spec = Shape.stateSpec; - }); - - it("transition result is always in stateSpec.states and (from, event, to) in spec when state changes", () => { - const idle = { type: "idle" as const }; - const ready = { type: "ready" as const }; - const dragging = { - type: "dragging" as const, - startPos: p, - currentPos: p, - }; - - let result = shape.transition(idle, makePointerMove()); - assertResultInDiagram(spec, result, "idle", "pointerMove"); - - result = shape.transition(ready, makePointerMove()); - assertResultInDiagram(spec, result, "ready", "pointerMove"); - - result = shape.transition(ready, makeDragStart()); - assertResultInDiagram(spec, result, "ready", "dragStart"); - - result = shape.transition(dragging, makeDrag()); - assertResultInDiagram(spec, result, "dragging", "drag"); - - result = shape.transition(dragging, makeDragEnd()); - assertResultInDiagram(spec, result, "dragging", "dragEnd"); - - result = shape.transition(dragging, makeDragCancel()); - assertResultInDiagram(spec, result, "dragging", "dragCancel"); - }); - }); - - describe("Pen", () => { - let pen: Pen; - let spec: StateDiagram; - - beforeEach(() => { - editor.startSession({ glyphName: "A", unicode: 65 }); - pen = new Pen(editor); - spec = Pen.stateSpec; - }); - - it("transition result is always in stateSpec.states for sampled (state, event) pairs", () => { - const idle = { type: "idle" as const }; - const ready = { type: "ready" as const, mousePos: p }; - const anchor = { - type: "anchored" as const, - anchor: { - position: p, - pointId: asPointId("0"), - context: { - previousPointType: "none" as const, - previousOnCurvePosition: null, - isFirstPoint: true, - }, - }, - }; - const draggingState = { - type: "dragging" as const, - anchor: anchor.anchor, - handles: {} as Handles, - mousePos: p, - }; - - let result = pen.transition(idle, makePointerMove()); - expect(spec.states).toContain(result.type); - - result = pen.transition(ready, makePointerMove()); - expect(spec.states).toContain(result.type); - - result = pen.transition(ready, makeClick()); - expect(spec.states).toContain(result.type); - - result = pen.transition(anchor, makeDragStart()); - expect(spec.states).toContain(result.type); - - result = pen.transition(draggingState, makeDrag()); - expect(spec.states).toContain(result.type); - - result = pen.transition(draggingState, makeDragEnd()); - expect(spec.states).toContain(result.type); - }); - }); - - describe("Select", () => { - let select: Select; - let spec: StateDiagram; - - beforeEach(() => { - select = new Select(editor); - spec = Select.stateSpec; - }); - - it("transition result is always in stateSpec.states for sampled (state, event) pairs", () => { - const idle = { type: "idle" as const }; - const ready = { type: "ready" as const }; - const selected = { type: "selected" as const }; - - let result = select.transition(idle, makePointerMove()); - expect(spec.states).toContain(result.type); - - result = select.transition(ready, makePointerMove()); - expect(spec.states).toContain(result.type); - - result = select.transition(ready, makeClick()); - expect(spec.states).toContain(result.type); - - result = select.transition(ready, makeSelectionChanged()); - expect(spec.states).toContain(result.type); - - result = select.transition(selected, makeKeyDownEscape()); - expect(spec.states).toContain(result.type); - - result = select.transition(selected, makeSelectionChanged()); - expect(spec.states).toContain(result.type); - }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.test.ts deleted file mode 100644 index 915056c9..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { defineStateDiagram, type StateDiagram } from "./StateDiagram"; -import { stateDiagramToMermaid } from "./stateDiagramToMermaid"; - -describe("StateDiagram", () => { - describe("defineStateDiagram", () => { - it("creates a valid state diagram", () => { - const diagram = defineStateDiagram({ - states: ["idle", "ready", "active"], - initial: "idle", - transitions: [ - { from: "idle", to: "ready", event: "activate" }, - { from: "ready", to: "active", event: "start" }, - ], - }); - - expect(diagram.states).toEqual(["idle", "ready", "active"]); - expect(diagram.initial).toBe("idle"); - expect(diagram.transitions).toHaveLength(2); - }); - - it("enforces type safety for state names", () => { - type TestState = { type: "a" | "b" | "c" }; - - const diagram = defineStateDiagram({ - states: ["a", "b", "c"], - initial: "a", - transitions: [ - { from: "a", to: "b", event: "next" }, - { from: "b", to: "c", event: "next" }, - ], - }); - - expect(diagram.states).toContain("a"); - expect(diagram.states).toContain("b"); - expect(diagram.states).toContain("c"); - }); - }); - - describe("stateDiagramToMermaid", () => { - const simpleDiagram: StateDiagram = { - states: ["idle", "ready", "active"], - initial: "idle", - transitions: [ - { from: "idle", to: "ready", event: "activate" }, - { from: "ready", to: "active", event: "start" }, - { from: "active", to: "idle", event: "stop" }, - ], - }; - - it("generates basic mermaid syntax", () => { - const mermaid = stateDiagramToMermaid(simpleDiagram); - - expect(mermaid).toContain("stateDiagram-v2"); - expect(mermaid).toContain("[*] --> idle"); - expect(mermaid).toContain("idle --> ready: activate"); - expect(mermaid).toContain("ready --> active: start"); - expect(mermaid).toContain("active --> idle: stop"); - }); - - it("adds highlighting for current state", () => { - const mermaid = stateDiagramToMermaid(simpleDiagram, "ready"); - - expect(mermaid).toContain("classDef active fill:#f96,stroke:#333,stroke-width:2px"); - expect(mermaid).toContain("class ready active"); - }); - - it("does not add highlighting for invalid state", () => { - const mermaid = stateDiagramToMermaid(simpleDiagram, "nonexistent"); - - expect(mermaid).not.toContain("classDef active"); - expect(mermaid).not.toContain("class"); - }); - - it("does not add highlighting when no state provided", () => { - const mermaid = stateDiagramToMermaid(simpleDiagram); - - expect(mermaid).not.toContain("classDef active"); - }); - - it("handles empty transitions", () => { - const diagram: StateDiagram = { - states: ["idle"], - initial: "idle", - transitions: [], - }; - - const mermaid = stateDiagramToMermaid(diagram); - - expect(mermaid).toContain("stateDiagram-v2"); - expect(mermaid).toContain("[*] --> idle"); - }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.ts b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.ts index 96de00be..8700dd78 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/StateDiagram.ts @@ -13,12 +13,3 @@ export interface StateDiagram { export function defineStateDiagram(diagram: StateDiagram): StateDiagram { return diagram; } - -export function transitionInDiagram( - spec: StateDiagram, - from: string, - eventName: string, - to: string, -): boolean { - return spec.transitions.some((t) => t.from === from && t.event === eventName && t.to === to); -} diff --git a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts index c886c7ed..069977ee 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Editor } from "@/lib/editor/Editor"; import type { ToolSwitchHandler, TemporaryToolOptions } from "@/types/editor"; import type { ToolName } from "./createContext"; diff --git a/apps/desktop/src/renderer/src/lib/tools/core/index.ts b/apps/desktop/src/renderer/src/lib/tools/core/index.ts index 1d9dc88b..385d7c9f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/index.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/index.ts @@ -11,11 +11,5 @@ export { ToolManager } from "./ToolManager"; export { type ToolName, type BuiltInToolId, BUILT_IN_TOOL_IDS } from "./createContext"; export type { ToolFactory, ToolManifest } from "./ToolManifest"; export type { ToolStateMap, ActiveToolState } from "./ToolStateMap"; -export { - defineStateDiagram, - transitionInDiagram, - type StateDiagram, - type StateTransition, -} from "./StateDiagram"; +export { defineStateDiagram, type StateDiagram, type StateTransition } from "./StateDiagram"; export { createBehavior, type Behavior, type ToolContext } from "./Behavior"; -export { stateDiagramToMermaid } from "./stateDiagramToMermaid"; diff --git a/apps/desktop/src/renderer/src/lib/tools/core/stateDiagramToMermaid.ts b/apps/desktop/src/renderer/src/lib/tools/core/stateDiagramToMermaid.ts deleted file mode 100644 index 6ecb3a5a..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/core/stateDiagramToMermaid.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StateDiagram } from "./StateDiagram"; - -export function stateDiagramToMermaid(spec: StateDiagram, highlightState?: string): string { - const lines = ["stateDiagram-v2"]; - lines.push(` [*] --> ${spec.initial}`); - - for (const t of spec.transitions) { - lines.push(` ${t.from} --> ${t.to}: ${t.event}`); - } - - if (highlightState && spec.states.includes(highlightState)) { - lines.push(` classDef active fill:#f96,stroke:#333,stroke-width:2px`); - lines.push(` class ${highlightState} active`); - } - - return lines.join("\n"); -} diff --git a/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md index feeb207c..198d7785 100644 --- a/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md @@ -8,7 +8,7 @@ State machine-based tool system for the Shift font editor: translates pointer/ke - **Architecture Invariant:** Behaviors are tried in **array order**; first handler that returns `true` wins. Reordering the `behaviors` array changes tool semantics. **CRITICAL**: placing a broad handler (e.g. `Selection`) before a narrow one (e.g. `ToggleSmooth`) will shadow the narrow handler. -- **Architecture Invariant:** Behaviors are stateless transition rules. All mutable state lives in the tool state union `S` or on `Editor`. Behaviors must not hold state that survives across events (private fields for long-lived resources like `GlyphDraft` or `DragSnapSession` are the exception, cleaned up in `onStateEnter`/`onStateExit`). +- **Architecture Invariant:** Behaviors are stateless transition rules. All mutable state lives in the tool state union `S` or on `Editor`. Behaviors must not hold state that survives across events unless that resource is cleaned up in `onStateEnter`/`onStateExit`. - **Architecture Invariant:** Behaviors do NOT render. All rendering belongs in the tool's `renderOverlay` / `renderScene` / `renderBackground` methods. @@ -30,7 +30,7 @@ tools/ GestureDetector.ts — pointer+timing -> ToolEvent (click, drag, doubleClick, ...) ToolManager.ts — tool orchestration, rAF coalescing, temporary tool override ToolManifest.ts — ToolManifest registration descriptor - StateDiagram.ts — defineStateDiagram, transitionInDiagram for compliance tests + StateDiagram.ts — defineStateDiagram for declarative tool state specs ToolStateMap.ts — union map of all built-in tool states createContext.ts — ToolName, ToolState, BUILT_IN_TOOL_IDS hand/ — canvas panning (createBehavior style) @@ -101,12 +101,12 @@ After `#runBehaviors`, if `next !== prev` (reference equality): ### Draft pattern for drag mutations -Behaviors that move geometry (Translate, Resize, Rotate, BendCurve) use `GlyphDraft`: +Behaviors that move source geometry use `SourceEditDraft`: -1. `editor.createDraft()` on drag start -- snapshots the glyph. -2. `draft.setPositions(updates)` on each drag event -- applies deltas to the snapshot. -3. `draft.finish(label)` on drag end -- commits as an undoable command. -4. `draft.discard()` on drag cancel -- restores the original snapshot. +1. `editor.beginSourceEditDraft(subject)` on drag start -- captures source positions for selected points/anchors. +2. `draft.preview(...)` on each drag event -- applies preview positions to the reactive source. +3. `draft.commit(label)` on drag end -- commits the final positions as an undoable command. +4. `draft.discard()` on drag cancel -- restores the captured source positions. ### Rendering layers @@ -230,10 +230,9 @@ onDragCancel(state, ctx) { ## Related -- `Editor` — provides all services tools access via `this.editor` (hit-testing, selection, hover, commands, viewport, glyph, snapping). +- `Editor` — provides all services tools access via `this.editor` (hit-testing, selection, hover, commands, viewport, glyph). - `Canvas` — rendering target passed to `renderOverlay` / `renderScene` / `renderBackground`. - `GlyphDraft` — preview-and-commit pattern for drag mutations (translate, resize, rotate, bend). -- `DragSnapSession` — snapping during point drags; created via `editor.createDragSnapSession`. - `Coordinates` — `{ screen, scene, glyphLocal }` coordinate bundle on pointer events. - `TextRunController` — text input controller used by Text tool. - `KeyboardRouter` — binds tool shortcuts registered via `getToolShortcuts`. diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/Hand.ts b/apps/desktop/src/renderer/src/lib/tools/hand/Hand.ts index aac714bd..124cd2bd 100644 --- a/apps/desktop/src/renderer/src/lib/tools/hand/Hand.ts +++ b/apps/desktop/src/renderer/src/lib/tools/hand/Hand.ts @@ -4,6 +4,7 @@ import type { HandState } from "./types"; import { HandReadyBehavior, HandDraggingBehavior } from "./behaviors"; export class Hand extends BaseTool { + /** @knipclassignore — declarative state spec for tool docs/debugging. */ static stateSpec = defineStateDiagram({ states: ["idle", "ready", "dragging"], initial: "idle", diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/types.ts b/apps/desktop/src/renderer/src/lib/tools/hand/types.ts index 80a0423c..84cc25a7 100644 --- a/apps/desktop/src/renderer/src/lib/tools/hand/types.ts +++ b/apps/desktop/src/renderer/src/lib/tools/hand/types.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Behavior } from "../core/Behavior"; export type HandState = diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts index ab9be32e..39f95b16 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts @@ -1,6 +1,4 @@ -import { Vec2 } from "@shift/geo"; -import { Contours } from "@shift/font"; -import type { Point2D } from "@shift/types"; +import { Vec2, type Point2D } from "@shift/geo"; import { BaseTool, type ToolName, defineStateDiagram, type ToolEvent } from "../core"; import type { PenState } from "./types"; import { PenDownBehaviour, HandleBehavior, EscapeBehavior } from "./behaviors"; @@ -11,6 +9,7 @@ import type { Canvas } from "@/lib/editor/rendering/Canvas"; export type { PenState }; export class Pen extends BaseTool { + /** @knipclassignore — declarative state spec for tool docs/debugging. */ static stateSpec = defineStateDiagram({ states: ["idle", "ready", "anchored", "dragging"], initial: "idle", @@ -55,8 +54,7 @@ export class Pen extends BaseTool { override activate(): void { const pos = this.editor.sceneToGlyphLocal(this.editor.getMousePosition()); this.state = { type: "ready", mousePos: pos }; - const glyph = this.editor.glyph.peek(); - if (glyph) glyph.clearActiveContour(); + this.editor.clearActiveContour(); } override deactivate(): void { @@ -78,7 +76,7 @@ export class Pen extends BaseTool { return false; } - const firstPoint = Contours.firstPoint(contour); + const firstPoint = contour.firstPoint; if (!firstPoint) return false; return Vec2.isWithin({ x, y }, firstPoint, this.editor.hitRadius); @@ -87,16 +85,16 @@ export class Pen extends BaseTool { private hasActiveDrawingContour(): boolean { const contour = this.editor.getActiveContour(); if (!contour) return false; - return Contours.isOpen(contour) && !Contours.isEmpty(contour); + return !contour.closed && !contour.isEmpty; } private getLastOnCurvePoint(): Point2D | null { const contour = this.editor.getActiveContour(); - if (!contour || Contours.isEmpty(contour) || contour.closed) { + if (!contour || contour.isEmpty || contour.closed) { return null; } - const lastOnCurve = Contours.lastOnCurvePoint(contour); + const lastOnCurve = contour.lastOnCurvePoint; if (!lastOnCurve) return null; return { x: lastOnCurve.x, y: lastOnCurve.y }; @@ -124,8 +122,8 @@ export class Pen extends BaseTool { // Control handle preview during drag if (this.state.type === "dragging") { - const { anchor, mousePos, snappedPos } = this.state; - const effectivePos = snappedPos ?? mousePos; + const { anchor, mousePos } = this.state; + const effectivePos = mousePos; const mirrorPos = Vec2.mirror(effectivePos, anchor.position); const { stroke, widthPx } = canvas.theme.glyph; diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts index 97a9553b..7e5bfc57 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts @@ -1,4 +1,3 @@ -import { Contours } from "@shift/font"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; import type { ToolEventOf } from "../../core/GestureDetector"; @@ -20,6 +19,6 @@ export class EscapeBehavior implements PenBehavior { private hasActiveDrawingContour(editor: Editor): boolean { const contour = editor.getActiveContour(); if (!contour) return false; - return Contours.isOpen(contour) && !Contours.isEmpty(contour); + return !contour.closed && !contour.isEmpty; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts index f98c6fb4..87cdb179 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts @@ -1,18 +1,14 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import { Vec2 } from "@shift/geo"; -import { Contours } from "@shift/font"; -import { Validate } from "@shift/validation"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { PenState, PenBehavior, Anchor, Handles } from "../types"; -import type { DragSnapSession } from "@/lib/editor/snapping/types"; +import { PenStroke } from "./PenStroke"; const DRAG_THRESHOLD = 3; export class HandleBehavior implements PenBehavior { - #snap: DragSnapSession | null = null; - onDrag(state: PenState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { if (state.type === "anchored") { const next = this.#nextAnchoredState(state, event, ctx.editor); @@ -32,16 +28,7 @@ export class HandleBehavior implements PenBehavior { if (state.type !== "anchored" && state.type !== "dragging") return false; if (state.type === "anchored" && !state.anchor.pointId) { - const glyph = ctx.editor.glyph.peek(); - const contour = ctx.editor.getActiveContour(); - if (glyph && contour) { - glyph.addPointToContour(contour.id, { - x: state.anchor.position.x, - y: state.anchor.position.y, - pointType: "onCurve", - smooth: false, - }); - } + PenStroke.active(ctx.editor)?.commitAnchor(state.anchor); } ctx.setState({ type: "ready", mousePos: event.coords.glyphLocal }); @@ -65,8 +52,7 @@ export class HandleBehavior implements PenBehavior { onStateEnter(prev: PenState, next: PenState, ctx: ToolContext): void { if ((prev.type === "anchored" || prev.type === "dragging") && next.type === "ready") { - this.#clearSnap(); - ctx.editor.setSnapIndicator(null); + ctx.editor.requestRedraw(); } } @@ -80,23 +66,11 @@ export class HandleBehavior implements PenBehavior { const handles = this.#createHandles(state.anchor, localPoint, editor); - if (state.anchor.pointId) { - this.#startSnap(editor, state.anchor); - } - - let snappedPos = localPoint; - if (this.#snap) { - const result = this.#snap.snap(localPoint, { shiftKey: event.shiftKey }); - snappedPos = result.point; - editor.setSnapIndicator(result.indicator); - } - return { type: "dragging", anchor: state.anchor, handles, mousePos: localPoint, - ...(event.shiftKey ? { snappedPos } : {}), }; } @@ -107,119 +81,19 @@ export class HandleBehavior implements PenBehavior { ): PenState & { type: "dragging" } { const localPoint = event.coords.glyphLocal; - let snappedPos = localPoint; - if (this.#snap) { - const result = this.#snap.snap(localPoint, { shiftKey: event.shiftKey }); - snappedPos = result.point; - editor.setSnapIndicator(result.indicator); - } - - this.#updateHandles(state.anchor, state.handles, snappedPos, editor); + this.#updateHandles(state.anchor, state.handles, localPoint, editor); return { ...state, mousePos: localPoint, - ...(event.shiftKey ? { snappedPos } : {}), }; } - #createHandles(anchor: Anchor, snappedPos: Point2D, editor: Editor): Handles { - const glyph = editor.glyph.peek(); - if (!glyph) return {}; - - const { position } = anchor; - const contour = editor.getActiveContour(); - - if (!contour) return {}; - - const prevPoint = Contours.lastPoint(contour); - const prevOnCurve = Contours.lastOnCurvePoint(contour); - const isFirstPoint = Contours.isEmpty(contour); - - const anchorId = glyph.addPointToContour(contour.id, { - x: position.x, - y: position.y, - pointType: "onCurve", - smooth: true, - }); - anchor.pointId = anchorId; - - if (isFirstPoint) { - const cpOutId = glyph.addPointToContour(contour.id, { - x: snappedPos.x, - y: snappedPos.y, - pointType: "offCurve", - smooth: false, - }); - return { cpOut: cpOutId }; - } - - const prevIsOffCurve = prevPoint && Validate.isOffCurve(prevPoint); - - if (prevIsOffCurve) { - const cpInPos = Vec2.mirror(snappedPos, position); - const cpInId = glyph.insertPointBefore(anchorId, { - x: cpInPos.x, - y: cpInPos.y, - pointType: "offCurve", - smooth: false, - }); - const cpOutId = glyph.addPointToContour(contour.id, { - x: snappedPos.x, - y: snappedPos.y, - pointType: "offCurve", - smooth: false, - }); - return { cpIn: cpInId, cpOut: cpOutId }; - } - - if (prevOnCurve) { - const cp1Pos = Vec2.lerp(prevOnCurve, position, 1 / 3); - glyph.insertPointBefore(anchorId, { - x: cp1Pos.x, - y: cp1Pos.y, - pointType: "offCurve", - smooth: false, - }); - } - - const cpInPos = Vec2.mirror(snappedPos, position); - const cpInId = glyph.insertPointBefore(anchorId, { - x: cpInPos.x, - y: cpInPos.y, - pointType: "offCurve", - smooth: false, - }); - return { cpIn: cpInId }; - } - - #updateHandles(anchor: Anchor, handles: Handles, snappedPos: Point2D, editor: Editor): void { - const glyph = editor.glyph.peek(); - if (!glyph) return; - - if (handles.cpOut) { - glyph.movePointTo(handles.cpOut, snappedPos); - } - - if (handles.cpIn) { - const mirror = Vec2.mirror(snappedPos, anchor.position); - glyph.movePointTo(handles.cpIn, mirror); - } - } - - #startSnap(editor: Editor, anchor: Anchor): void { - if (!anchor.pointId) return; - - this.#clearSnap(); - this.#snap = editor.createDragSnapSession({ - anchorPointId: anchor.pointId, - dragStart: anchor.position, - excludedPointIds: [], - }); + #createHandles(anchor: Anchor, handlePos: Point2D, editor: Editor): Handles { + return PenStroke.active(editor)?.createHandles(anchor, handlePos) ?? {}; } - #clearSnap(): void { - if (this.#snap) this.#snap.clear(); - this.#snap = null; + #updateHandles(anchor: Anchor, handles: Handles, handlePos: Point2D, editor: Editor): void { + PenStroke.active(editor)?.moveHandles(anchor, handles, handlePos); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts index 8abf1d04..2e68cb4b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts @@ -1,37 +1,31 @@ -import { Contours } from "@shift/font"; import type { ToolContext } from "../../core/Behavior"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { PenState, PenBehavior } from "../types"; +import { PenStroke } from "./PenStroke"; export class PenDownBehaviour implements PenBehavior { onClick(state: PenState, ctx: ToolContext, event: ToolEventOf<"click">): boolean { if (state.type !== "ready") return false; const editor = ctx.editor; - const glyph = editor.glyph.peek(); - if (!glyph) return false; + const stroke = PenStroke.active(editor); + if (!stroke) return false; const localPoint = event.coords.glyphLocal; - const activeContour = editor.getActiveContour(); + const activeContour = stroke.activeContour; editor.selection.clear(); const hit = editor.hitTest(event.coords); if (!activeContour && !hit) { - const contourId = glyph.addContour(); - glyph.addPointToContour(contourId, { - x: localPoint.x, - y: localPoint.y, - pointType: "onCurve", - smooth: false, - }); + stroke.startContour(localPoint); ctx.setState({ type: "ready", mousePos: localPoint }); return true; } - if (activeContour && Contours.canClose(activeContour, localPoint, editor.hitRadius)) { - glyph.closeContour(); + if (activeContour && stroke.canClose(localPoint, editor.hitRadius)) { + stroke.closeActiveContour(); ctx.setState({ type: "ready", mousePos: localPoint }); return true; } @@ -52,12 +46,7 @@ export class PenDownBehaviour implements PenBehavior { if (!activeContour) return false; - glyph.addPointToContour(activeContour.id, { - x: localPoint.x, - y: localPoint.y, - pointType: "onCurve", - smooth: false, - }); + stroke.appendOnCurve(localPoint); ctx.setState({ type: "ready", mousePos: localPoint }); return true; } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenStroke.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenStroke.ts new file mode 100644 index 00000000..da1bc5e5 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenStroke.ts @@ -0,0 +1,135 @@ +import { Vec2, type Point2D } from "@shift/geo"; +import { Validate } from "@shift/validation"; +import type { ContourId, PointId } from "@shift/types"; +import type { Editor } from "@/lib/editor/Editor"; +import type { GlyphSource, SourcePositions } from "@/lib/model/Glyph"; +import type { Contour } from "@shift/glyph-state"; +import type { Anchor, Handles } from "../types"; + +type PointKind = "onCurve" | "offCurve"; + +export class PenStroke { + readonly #editor: Editor; + readonly #source: GlyphSource; + + private constructor(editor: Editor, source: GlyphSource) { + this.#editor = editor; + this.#source = source; + } + + static active(editor: Editor): PenStroke | null { + const source = editor.activeGlyphSource; + return source ? new PenStroke(editor, source) : null; + } + + get activeContour(): Contour | null { + const contourId = this.#editor.getActiveContourId(); + return contourId ? this.#source.contour(contourId) : null; + } + + startContour(position: Point2D): PointId { + const contourId = this.#source.addContour(); + this.#editor.setActiveContour(contourId); + return this.#addPoint(contourId, position, "onCurve", false); + } + + appendOnCurve(position: Point2D): PointId | null { + const contour = this.activeContour; + if (!contour) return null; + return this.#addPoint(contour.id, position, "onCurve", false); + } + + closeActiveContour(): boolean { + const contour = this.activeContour; + if (!contour) return false; + + this.#source.closeContour(contour.id); + this.#editor.clearActiveContour(); + return true; + } + + canClose(position: Point2D, hitRadius: number): boolean { + const contour = this.activeContour; + return contour ? contour.canClose(position, hitRadius) : false; + } + + commitAnchor(anchor: Anchor): PointId | null { + if (anchor.pointId) return anchor.pointId; + + const pointId = this.appendOnCurve(anchor.position); + if (pointId) anchor.pointId = pointId; + return pointId; + } + + createHandles(anchor: Anchor, handlePos: Point2D): Handles { + const { position } = anchor; + const contour = this.activeContour; + if (!contour) return {}; + + const prevPoint = contour.lastPoint; + const prevOnCurve = contour.lastOnCurvePoint; + const isFirstPoint = contour.isEmpty; + + const anchorId = this.#addPoint(contour.id, position, "onCurve", true); + anchor.pointId = anchorId; + + if (isFirstPoint) { + const cpOutId = this.#addPoint(contour.id, handlePos, "offCurve", false); + return { cpOut: cpOutId }; + } + + const prevIsOffCurve = prevPoint && Validate.isOffCurve(prevPoint); + + if (prevIsOffCurve) { + const cpInPos = Vec2.mirror(handlePos, position); + const cpInId = this.#source.insertPointBefore( + anchorId, + pointEdit(cpInPos, "offCurve", false), + ); + const cpOutId = this.#addPoint(contour.id, handlePos, "offCurve", false); + return { cpIn: cpInId, cpOut: cpOutId }; + } + + if (prevOnCurve) { + const cp1Pos = Vec2.lerp(prevOnCurve, position, 1 / 3); + this.#source.insertPointBefore(anchorId, pointEdit(cp1Pos, "offCurve", false)); + } + + const cpInPos = Vec2.mirror(handlePos, position); + const cpInId = this.#source.insertPointBefore(anchorId, pointEdit(cpInPos, "offCurve", false)); + return { cpIn: cpInId }; + } + + moveHandles(anchor: Anchor, handles: Handles, handlePos: Point2D): void { + const positions: SourcePositions[number][] = []; + + if (handles.cpOut) { + positions.push({ + kind: "point", + id: handles.cpOut, + x: handlePos.x, + y: handlePos.y, + }); + } + + if (handles.cpIn) { + const mirror = Vec2.mirror(handlePos, anchor.position); + positions.push({ kind: "point" as const, id: handles.cpIn, x: mirror.x, y: mirror.y }); + } + + this.#source.setPositions(positions); + } + + #addPoint( + contourId: ContourId, + position: Point2D, + pointType: PointKind, + smooth: boolean, + ): PointId { + return this.#source.addPoint(contourId, pointEdit(position, pointType, smooth)); + } +} + +function pointEdit(position: Point2D, pointType: PointKind, smooth: boolean) { + return { x: position.x, y: position.y, pointType, smooth }; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/types.ts b/apps/desktop/src/renderer/src/lib/tools/pen/types.ts index d69082bd..75d13333 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/types.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/types.ts @@ -1,4 +1,5 @@ -import type { Point2D, PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; import type { Behavior } from "../core/Behavior"; export interface Anchor { @@ -20,7 +21,6 @@ export type PenState = anchor: Anchor; handles: Handles; mousePos: Point2D; - snappedPos?: Point2D; }; export type PenBehavior = Behavior; diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts index e4cee650..157abc8e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts @@ -23,6 +23,7 @@ import type { Canvas } from "@/lib/editor/rendering/Canvas"; export type { BoundingRectEdge, SelectState }; export class Select extends BaseTool { + /** @knipclassignore — declarative state spec for tool docs/debugging. */ static stateSpec = defineStateDiagram({ states: [ "idle", diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts index 995bf1d0..e87f2d9e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts @@ -2,10 +2,10 @@ import { Vec2 } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { GlyphDraft } from "@/types/draft"; +import type { SourceEditDraft } from "@/lib/editor/SourceEditDraft"; export class BendCurve implements SelectBehavior { - #draft: GlyphDraft | null = null; + #draft: SourceEditDraft | null = null; #hasChanges = false; onDragStart( @@ -23,7 +23,7 @@ export class BendCurve implements SelectBehavior { if (!cubic) return true; const { control1, control2 } = cubic.points; - this.#draft = ctx.editor.createDraft(); + this.#draft = ctx.editor.beginSourceEditDraft({ points: [control1.id, control2.id] }); this.#hasChanges = false; ctx.setState({ @@ -64,10 +64,10 @@ export class BendCurve implements SelectBehavior { const { control1, control2 } = cubic.points; const updates = [ - { node: { kind: "point" as const, id: control1.id }, x: newCp1.x, y: newCp1.y }, - { node: { kind: "point" as const, id: control2.id }, x: newCp2.x, y: newCp2.y }, + { kind: "point" as const, id: control1.id, x: newCp1.x, y: newCp1.y }, + { kind: "point" as const, id: control2.id, x: newCp2.x, y: newCp2.y }, ]; - this.#draft.setPositions(updates); + this.#draft.previewPositions(updates); this.#hasChanges = true; return true; } @@ -76,7 +76,7 @@ export class BendCurve implements SelectBehavior { if (state.type !== "bending") return false; if (this.#hasChanges) { - this.#draft?.finish("Bend curve"); + this.#draft?.commit("Bend curve"); } else { this.#draft?.discard(); } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts index 166b3bc5..fa68e24a 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts @@ -1,4 +1,5 @@ -import type { PointId, Rect2D } from "@shift/types"; +import type { Rect2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; import type { ToolContext } from "../../core/Behavior"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts index b1b9b258..6ce3b2de 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts @@ -1,18 +1,13 @@ -import type { GlyphSnapshot, Point2D, Rect2D } from "@shift/types"; -import { Vec2 } from "@shift/geo"; -import { Glyphs } from "@shift/font"; +import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; -import type { DragTarget } from "../types"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import type { BoundingRectEdge } from "../cursor"; -import type { GlyphDraft } from "@/types/draft"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; +import type { SourceEditDraft } from "@/lib/editor/SourceEditDraft"; export class Resize implements SelectBehavior { - #draft: GlyphDraft | null = null; - #target: DragTarget | null = null; + #draft: SourceEditDraft | null = null; #origin: Point2D | null = null; onDragStart( @@ -31,7 +26,7 @@ export class Resize implements SelectBehavior { onDrag(state: SelectState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { if (state.type !== "resizing") return false; - if (!this.#draft || !this.#target || !this.#origin) return false; + if (!this.#draft || !this.#origin) return false; const next = this.nextResizingState(state, event); ctx.setState(next); @@ -40,7 +35,7 @@ export class Resize implements SelectBehavior { onDragEnd(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "resizing") return false; - this.#draft?.finish("Scale Points"); + this.#draft?.commit("Scale Points"); this.#cleanup(); ctx.setState({ type: "selected" }); return true; @@ -63,7 +58,6 @@ export class Resize implements SelectBehavior { #cleanup(): void { this.#draft = null; - this.#target = null; this.#origin = null; } @@ -80,8 +74,7 @@ export class Resize implements SelectBehavior { uniformScale, ); - const updates = buildResizeUpdates(this.#draft!.base, this.#target!, this.#origin!, sx, sy); - this.#draft!.setPositions(updates); + this.#draft!.previewScale(sx, sy, this.#origin!); return { type: "resizing", @@ -108,11 +101,10 @@ export class Resize implements SelectBehavior { const localPoint = event.coords.glyphLocal; const anchorPoint = this.getAnchorPointForEdge(edge, bounds); - this.#draft = editor.createDraft(); - this.#target = { - pointIds: [...editor.selection.pointIds], - anchorIds: [...editor.selection.anchorIds], - }; + this.#draft = editor.beginSourceEditDraft({ + points: [...editor.selection.pointIds], + anchors: [...editor.selection.anchorIds], + }); this.#origin = anchorPoint; return { @@ -213,43 +205,3 @@ export class Resize implements SelectBehavior { return { sx, sy }; } } - -function scaleAround(point: Point2D, origin: Point2D, scaleX: number, scaleY: number): Point2D { - const offset = Vec2.sub(point, origin); - return Vec2.add(origin, { - x: offset.x * scaleX, - y: offset.y * scaleY, - }); -} - -function buildResizeUpdates( - base: GlyphSnapshot, - target: DragTarget, - origin: Point2D, - scaleX: number, - scaleY: number, -): NodePositionUpdateList { - const updates: Array = []; - - for (const point of Glyphs.findPoints(base, target.pointIds)) { - const next = scaleAround(point, origin, scaleX, scaleY); - updates.push({ - node: { kind: "point", id: point.id }, - x: next.x, - y: next.y, - }); - } - - for (const anchorId of target.anchorIds) { - const anchor = base.anchors.find((item) => item.id === anchorId); - if (!anchor) continue; - const next = scaleAround(anchor, origin, scaleX, scaleY); - updates.push({ - node: { kind: "anchor", id: anchorId }, - x: next.x, - y: next.y, - }); - } - - return updates; -} diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts index 9b00ffb7..1313681f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts @@ -1,21 +1,13 @@ -import { Vec2 } from "@shift/geo"; -import { Glyphs } from "@shift/font"; -import type { GlyphSnapshot, Point2D } from "@shift/types"; +import { Vec2, type Point2D } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import type { CornerHandle } from "@/types/boundingBox"; -import type { RotateSnapSession } from "@/lib/editor/snapping/types"; -import type { GlyphDraft } from "@/types/draft"; - -import type { NodePositionUpdateList } from "@/types/positionUpdate"; -import type { DragTarget } from "../types"; +import type { SourceEditDraft } from "@/lib/editor/SourceEditDraft"; export class Rotate implements SelectBehavior { - #snap: RotateSnapSession | null = null; - #draft: GlyphDraft | null = null; - #target: DragTarget | null = null; + #draft: SourceEditDraft | null = null; #origin: Point2D | null = null; onDragStart( @@ -34,7 +26,7 @@ export class Rotate implements SelectBehavior { onDrag(state: SelectState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { if (state.type !== "rotating") return false; - if (!this.#draft || !this.#target || !this.#origin) return false; + if (!this.#draft || !this.#origin) return false; const next = this.nextRotatingState(state, event); ctx.setState(next); @@ -44,7 +36,7 @@ export class Rotate implements SelectBehavior { onDragEnd(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "rotating") return false; - this.#draft?.finish("Rotate Points"); + this.#draft?.commit("Rotate Points"); this.#cleanup(ctx.editor); ctx.setState({ type: "selected" }); return true; @@ -72,10 +64,8 @@ export class Rotate implements SelectBehavior { #cleanup(editor: Editor): void { this.#draft = null; - this.#target = null; this.#origin = null; - this.clearSnap(); - editor.setSnapIndicator(null); + editor.requestRedraw(); } private nextRotatingState( @@ -84,21 +74,11 @@ export class Rotate implements SelectBehavior { ): SelectState & { type: "rotating" } { const currentPos = event.coords.glyphLocal; const rawAngle = Vec2.angleTo(state.rotate.center, currentPos); - const rawDelta = rawAngle - state.rotate.startAngle; - - let deltaAngle = rawDelta; - let snappedAngle: number | undefined; - - if (this.#snap) { - const snapResult = this.#snap.snap(rawDelta, { shiftKey: event.shiftKey }); - deltaAngle = snapResult.delta; - if (snapResult.source === "angle") snappedAngle = snapResult.delta; - } + const deltaAngle = rawAngle - state.rotate.startAngle; const currentAngle = state.rotate.startAngle + deltaAngle; - const updates = buildRotateUpdates(this.#draft!.base, this.#target!, this.#origin!, deltaAngle); - this.#draft!.setPositions(updates); + this.#draft!.previewRotate(deltaAngle, this.#origin!); return { type: "rotating", @@ -106,7 +86,6 @@ export class Rotate implements SelectBehavior { ...state.rotate, lastPos: currentPos, currentAngle, - ...(snappedAngle !== undefined ? { snappedAngle } : {}), }, }; } @@ -129,13 +108,11 @@ export class Rotate implements SelectBehavior { ); const startAngle = Vec2.angleTo(center, localPoint); - this.startSnap(editor); - this.#draft = editor.createDraft(); - this.#target = { - pointIds: [...editor.selection.pointIds], - anchorIds: [...editor.selection.anchorIds], - }; + this.#draft = editor.beginSourceEditDraft({ + points: [...editor.selection.pointIds], + anchors: [...editor.selection.anchorIds], + }); this.#origin = center; return { @@ -150,45 +127,4 @@ export class Rotate implements SelectBehavior { }, }; } - - private startSnap(editor: Editor): void { - this.clearSnap(); - this.#snap = editor.createRotateSnapSession(); - } - - private clearSnap(): void { - if (this.#snap) this.#snap.clear(); - this.#snap = null; - } -} - -function buildRotateUpdates( - base: GlyphSnapshot, - target: DragTarget, - origin: Point2D, - angle: number, -): NodePositionUpdateList { - const updates: Array = []; - - for (const point of Glyphs.findPoints(base, target.pointIds)) { - const next = Vec2.rotateAround(point, origin, angle); - updates.push({ - node: { kind: "point", id: point.id }, - x: next.x, - y: next.y, - }); - } - - for (const anchorId of target.anchorIds) { - const anchor = base.anchors.find((item) => item.id === anchorId); - if (!anchor) continue; - const next = Vec2.rotateAround(anchor, origin, angle); - updates.push({ - node: { kind: "anchor", id: anchorId }, - x: next.x, - y: next.y, - }); - } - - return updates; } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts index bd4198d4..29c0a26f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts @@ -3,6 +3,7 @@ import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import { getPointIdFromHit } from "@/types/hitResult"; import { Validate } from "@shift/validation"; +import { ToggleSmoothCommand } from "@/lib/commands/primitives"; export class ToggleSmooth implements SelectBehavior { onDoubleClick( @@ -19,10 +20,7 @@ export class ToggleSmooth implements SelectBehavior { const point = ctx.editor.getAllPoints().find((p) => p.id === pointId); if (!point || !Validate.isOnCurve(point)) return false; - const glyph = ctx.editor.glyph.peek(); - if (!glyph) return false; - - glyph.toggleSmooth(pointId); + ctx.editor.commandHistory.execute(new ToggleSmoothCommand(pointId)); return true; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts index 918bcac2..70e7ba6b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts @@ -1,30 +1,24 @@ -import { Vec2 } from "@shift/geo"; -import { Glyphs } from "@shift/font"; -import type { AnchorId, GlyphSnapshot, Point2D, PointId } from "@shift/types"; +import { Vec2, type Point2D } from "@shift/geo"; +import type { AnchorId, PointId } from "@shift/types"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; -import type { DragTarget } from "../types"; import type { ToolEventOf } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { SegmentId } from "@/types/indicator"; -import { getPointIdFromHit, isAnchorHit, isSegmentHit } from "@/types/hitResult"; -import type { DragSnapSession } from "@/lib/editor/snapping/types"; -import type { GlyphDraft } from "@/types/draft"; +import { getPointIdFromHit, isAnchorHit, isSegmentHit, type HitResult } from "@/types/hitResult"; import { constrainPreparedDrag, - prepareConstrainDrag, + prepareConstrainedDrag, type PreparedConstrainDrag, } from "@shift/rules"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; +import type { GlyphSource, SourcePositions } from "@/lib/model/Glyph"; +import type { SourceEditDraft } from "@/lib/editor/SourceEditDraft"; type TranslatingState = Extract; +type TranslateStartState = SelectState & { type: "ready" | "selected" }; export class Translate implements SelectBehavior { - #snap: DragSnapSession | null = null; - #draft: GlyphDraft | null = null; - #target: DragTarget | null = null; - #rules: PreparedConstrainDrag | null = null; + #drag: TranslateDrag | null = null; onDragStart( state: SelectState, @@ -33,35 +27,36 @@ export class Translate implements SelectBehavior { ): boolean { if (state.type !== "ready" && state.type !== "selected") return false; - const nextState = this.tryStartDrag(state, event, ctx.editor); - if (!nextState) return false; + const target = TranslateTarget.fromDragStart(ctx.editor, state, event); + if (!target) return false; - ctx.setState(nextState); + target.applySelection(ctx.editor); + this.#drag = new TranslateDrag(ctx.editor, target); + ctx.setState(translatingState(event.point)); return true; } onDrag(state: SelectState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { if (state.type !== "translating") return false; - if (!this.#draft || !this.#target) return false; + if (!this.#drag) return false; - const nextState = this.nextTranslatingState(state, event, ctx.editor); + const nextState = this.#nextTranslatingState(state, event); ctx.setState(nextState); return true; } onDragEnd(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "translating") return false; - const label = this.#getDragLabel(); - this.#draft?.finish(label); - this.#cleanup(ctx.editor); + this.#drag?.commit(); + this.#cleanup(); ctx.setState({ type: "selected" }); return true; } onDragCancel(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "translating") return false; - this.#draft?.discard(); - this.#cleanup(ctx.editor); + this.#drag?.discard(); + this.#cleanup(); ctx.setState({ type: "selected" }); return true; } @@ -73,262 +68,236 @@ export class Translate implements SelectBehavior { } if (prev.type === "translating" && next.type !== "translating") { - this.#cleanup(editor); + this.#cleanup(); } } - #cleanup(editor: Editor): void { - this.#draft = null; - this.#target = null; - this.#rules = null; - this.clearSnap(); - editor.setSnapIndicator(null); - } - - #getDragLabel(): string { - if (!this.#target) return "Move Points"; - if (this.#target.pointIds.length > 0 && this.#target.anchorIds.length > 0) { - return "Move Selection"; - } - if (this.#target.anchorIds.length > 0) { - return "Move Anchors"; - } - return "Move Points"; + #cleanup(): void { + this.#drag?.discard(); + this.#drag = null; } - private nextTranslatingState( - state: TranslatingState, - event: ToolEventOf<"drag">, - editor: Editor, - ): TranslatingState { - let newLastPos = event.point; - - if (this.#snap) { - const result = this.#snap.snap(event.point, { shiftKey: event.shiftKey }); - newLastPos = result.point; - editor.setSnapIndicator(result.indicator); - } - - const totalDelta = Vec2.sub(newLastPos, state.translate.startPos); - const updates = buildTranslateUpdates( - this.#draft!.base, - this.#target!, - totalDelta, - this.#rules, - ); - this.#draft!.setPositions(updates); + #nextTranslatingState(state: TranslatingState, event: ToolEventOf<"drag">): TranslatingState { + const totalDelta = Vec2.sub(event.point, state.translate.startPos); + this.#drag!.preview(totalDelta); return { type: "translating", translate: { ...state.translate, - lastPos: newLastPos, + lastPos: event.point, totalDelta, }, }; } +} - private tryStartDrag( - state: SelectState & { type: "ready" | "selected" }, - event: ToolEventOf<"dragStart">, +class TranslateTarget { + readonly pointIds: readonly PointId[]; + readonly anchorIds: readonly AnchorId[]; + + readonly #select: ((editor: Editor) => void) | null; + + private constructor( + pointIds: readonly PointId[], + anchorIds: readonly AnchorId[], + select: ((editor: Editor) => void) | null, + ) { + this.pointIds = [...pointIds]; + this.anchorIds = [...anchorIds]; + this.#select = select; + } + + static fromDragStart( editor: Editor, - ): SelectState | null { + state: TranslateStartState, + event: ToolEventOf<"dragStart">, + ): TranslateTarget | null { const hit = editor.hitTest(event.coords); - const pointId = getPointIdFromHit(hit); - const anchorId = isAnchorHit(hit) ? hit.anchorId : null; - - if (anchorId !== null) { - const isSelected = - state.type === "selected" && editor.selection.isSelected({ kind: "anchor", id: anchorId }); - - if (!isSelected) { - const draggedPointIds: PointId[] = []; - const draggedAnchorIds = [anchorId]; - this.selectAnchor(editor, anchorId, false); - return this.beginTranslating(editor, event.point, draggedPointIds, draggedAnchorIds); - } - - const draggedPointIds = [...editor.selection.pointIds]; - const draggedAnchorIds = [...editor.selection.anchorIds]; - const snapAnchorPointId = draggedPointIds[0] ?? null; - let anchorPos = event.point; - if (snapAnchorPointId !== null) { - anchorPos = this.startSnap(editor, snapAnchorPointId, event.point, draggedPointIds); - } - return this.beginTranslating(editor, anchorPos, draggedPointIds, draggedAnchorIds); - } - if (pointId !== null) { - const isSelected = - state.type === "selected" && editor.selection.isSelected({ kind: "point", id: pointId }); + return ( + TranslateTarget.fromAnchorHit(editor, state, hit) ?? + TranslateTarget.fromPointHit(editor, state, event, hit) ?? + TranslateTarget.fromSegmentHit(editor, state, event, hit) + ); + } - if (event.altKey) { - const result = this.startDuplicateDrag(editor, event.point); - if (result) return result; - } + applySelection(editor: Editor): void { + if (!this.#select) return; + this.#select(editor); + } - const draggedPointIds = isSelected ? [...editor.selection.pointIds] : [pointId]; - const draggedAnchorIds = isSelected ? [...editor.selection.anchorIds] : []; - const anchorPos = this.startSnap(editor, pointId, event.point, draggedPointIds); + static fromAnchorHit( + editor: Editor, + state: TranslateStartState, + hit: HitResult, + ): TranslateTarget | null { + if (!isAnchorHit(hit)) return null; - if (!isSelected) { - this.selectPoint(editor, pointId, false); - } - return this.beginTranslating(editor, anchorPos, draggedPointIds, draggedAnchorIds); + const selected = + state.type === "selected" && + editor.selection.isSelected({ kind: "anchor", id: hit.anchorId }); + + if (selected) { + return TranslateTarget.fromSelection(editor); } - if (isSegmentHit(hit)) { - const pointIds = hit.segment.pointIds; - const isSelected = - state.type === "selected" && - editor.selection.isSelected({ kind: "segment", id: hit.segmentId }); + return new TranslateTarget([], [hit.anchorId], (editor) => { + editor.selection.select([{ kind: "anchor", id: hit.anchorId }]); + }); + } - if (event.altKey) { - const result = this.startDuplicateDrag(editor, event.point); - if (result) return result; - } + static fromPointHit( + editor: Editor, + state: TranslateStartState, + event: ToolEventOf<"dragStart">, + hit: HitResult, + ): TranslateTarget | null { + const pointId = getPointIdFromHit(hit); + if (pointId === null) return null; - const draggedPointIds = isSelected ? [...editor.selection.pointIds] : pointIds; - const draggedAnchorIds = isSelected ? [...editor.selection.anchorIds] : []; - const anchorPointId = draggedPointIds[0]; - if (!anchorPointId) return null; + if (event.altKey) { + return TranslateTarget.fromDuplicatedSelection(editor); + } - const anchorPos = this.startSnap(editor, anchorPointId, event.point, draggedPointIds); + const selected = + state.type === "selected" && editor.selection.isSelected({ kind: "point", id: pointId }); - if (!isSelected) { - this.selectSegment(editor, hit.segmentId, false); - } - return this.beginTranslating(editor, anchorPos, draggedPointIds, draggedAnchorIds); + if (selected) { + return TranslateTarget.fromSelection(editor); } - return null; + return new TranslateTarget([pointId], [], (editor) => { + editor.selection.select([{ kind: "point", id: pointId }]); + }); } - private startDuplicateDrag(editor: Editor, startPos: Point2D): SelectState | null { - const newPointIds = editor.duplicateSelection(); - const firstPointId = newPointIds[0]; - if (!firstPointId) return null; + static fromSegmentHit( + editor: Editor, + state: TranslateStartState, + event: ToolEventOf<"dragStart">, + hit: HitResult, + ): TranslateTarget | null { + if (!isSegmentHit(hit)) return null; - const anchorPos = this.startSnap(editor, firstPointId, startPos, newPointIds); - editor.selection.select(newPointIds.map((id) => ({ kind: "point", id }))); - return this.beginTranslating(editor, anchorPos, newPointIds, []); - } + const segmentPointIds = hit.segment.pointIds; + if (segmentPointIds.length === 0) return null; - private beginTranslating( - editor: Editor, - startPointer: Point2D, - draggedPointIds: PointId[], - draggedAnchorIds: AnchorId[], - ): TranslatingState { - this.#draft = editor.createDraft(); - this.#target = { - pointIds: draggedPointIds, - anchorIds: draggedAnchorIds, - }; - this.#rules = - draggedPointIds.length > 0 - ? prepareConstrainDrag(this.#draft.base, new Set(draggedPointIds)) - : null; + if (event.altKey) { + return TranslateTarget.fromDuplicatedSelection(editor); + } - return { - type: "translating", - translate: { - startPos: startPointer, - lastPos: startPointer, - totalDelta: { x: 0, y: 0 }, - }, - }; + const selected = + state.type === "selected" && + editor.selection.isSelected({ kind: "segment", id: hit.segmentId }); + + if (selected) { + return TranslateTarget.fromSelection(editor); + } + + const pointIds = segmentPointIds.map((id) => ({ kind: "point" as const, id })); + return new TranslateTarget(segmentPointIds, [], (editor) => { + editor.selection.select([{ kind: "segment", id: hit.segmentId }, ...pointIds]); + }); } - private startSnap( - editor: Editor, - anchorPointId: PointId, - dragStart: Point2D, - excludedPointIds: PointId[], - ): Point2D { - this.clearSnap(); - - this.#snap = editor.createDragSnapSession({ - anchorPointId, - dragStart, - excludedPointIds, + static fromDuplicatedSelection(editor: Editor): TranslateTarget | null { + const pointIds = editor.duplicateSelection(); + if (pointIds.length === 0) return null; + + return new TranslateTarget(pointIds, [], (editor) => { + editor.selection.select(pointIds.map((id) => ({ kind: "point" as const, id }))); }); + } - return this.#snap.getAnchorPosition(); + static fromSelection(editor: Editor): TranslateTarget { + return new TranslateTarget( + [...editor.selection.pointIds], + [...editor.selection.anchorIds], + null, + ); } +} + +function translatingState(startPos: Point2D): TranslatingState { + return { + type: "translating", + translate: { + startPos, + lastPos: startPos, + totalDelta: { x: 0, y: 0 }, + }, + }; +} - private clearSnap(): void { - if (this.#snap) this.#snap.clear(); - this.#snap = null; +class TranslateDrag { + readonly #target: TranslateTarget; + readonly #draft: SourceEditDraft; + readonly #constraint: ConstrainedTranslate | null; + + constructor(editor: Editor, target: TranslateTarget) { + this.#target = target; + this.#draft = editor.beginSourceEditDraft({ + points: target.pointIds, + anchors: target.anchorIds, + }); + this.#constraint = ConstrainedTranslate.fromGlyphSource( + this.#draft.glyphSource, + target.pointIds, + ); } - private selectPoint(editor: Editor, pointId: PointId, additive: boolean): void { - if (additive) { - editor.selection.add({ kind: "point", id: pointId }); + preview(delta: Point2D): void { + if (!this.#constraint) { + this.#draft.previewTranslate(delta); return; } - editor.selection.select([{ kind: "point", id: pointId }]); + this.#draft.previewPositions( + this.#constraint.positionsFor(this.#draft.basePositions, this.#target, delta), + ); } - private selectAnchor(editor: Editor, anchorId: AnchorId, additive: boolean): void { - if (additive) { - editor.selection.add({ kind: "anchor", id: anchorId }); - return; - } + commit(): void { + this.#draft.commit("Move Selection"); + } - editor.selection.select([{ kind: "anchor", id: anchorId }]); + discard(): void { + this.#draft.discard(); } +} - private selectSegment(editor: Editor, segmentId: SegmentId, additive: boolean): void { - const segment = editor.getSegmentById(segmentId); - if (!segment) return; - const pointIds = segment.pointIds; +class ConstrainedTranslate { + readonly #rules: PreparedConstrainDrag; - if (additive) { - editor.selection.add({ kind: "segment", id: segmentId }); - for (const pointId of pointIds) { - editor.selection.add({ kind: "point", id: pointId }); - } - return; - } + private constructor(rules: PreparedConstrainDrag) { + this.#rules = rules; + } - editor.selection.select([ - { kind: "segment", id: segmentId }, - ...pointIds.map((id) => ({ kind: "point" as const, id })), - ]); + static fromGlyphSource( + glyphSource: GlyphSource, + pointIds: readonly PointId[], + ): ConstrainedTranslate | null { + if (pointIds.length === 0) return null; + + return new ConstrainedTranslate(prepareConstrainedDrag(glyphSource, new Set(pointIds))); } -} -function buildTranslateUpdates( - base: GlyphSnapshot, - target: DragTarget, - delta: Point2D, - rules: PreparedConstrainDrag | null, -): NodePositionUpdateList { - const updates: Array = []; - - if (rules) { - const patch = constrainPreparedDrag(rules, delta, { includeMatchedRules: false }); - for (const u of patch.pointUpdates) { - updates.push({ node: { kind: "point", id: u.id }, x: u.x, y: u.y }); + positionsFor(base: SourcePositions, target: TranslateTarget, delta: Point2D): SourcePositions { + const updates: SourcePositions[number][] = []; + const patch = constrainPreparedDrag(this.#rules, delta, { includeMatchedRules: false }); + + for (const update of patch.pointUpdates) { + updates.push({ kind: "point", id: update.id, x: update.x, y: update.y }); } - } else { - for (const point of Glyphs.findPoints(base, target.pointIds)) { - updates.push({ - node: { kind: "point", id: point.id }, - x: point.x + delta.x, - y: point.y + delta.y, - }); + + const anchorIds = new Set(target.anchorIds); + for (const position of base) { + if (position.kind !== "anchor" || !anchorIds.has(position.id)) continue; + const next = Vec2.add(position, delta); + updates.push({ ...position, x: next.x, y: next.y }); } - } - for (const anchorId of target.anchorIds) { - const anchor = base.anchors.find((a) => a.id === anchorId); - if (!anchor) continue; - const next = Vec2.add(anchor, delta); - updates.push({ node: { kind: "anchor", id: anchorId }, x: next.x, y: next.y }); + return updates; } - - return updates; } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/types.ts b/apps/desktop/src/renderer/src/lib/tools/select/types.ts index 135f292d..29d38f97 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/types.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/types.ts @@ -1,4 +1,5 @@ -import type { AnchorId, Point2D, PointId, Rect2D } from "@shift/types"; +import type { Point2D, Rect2D } from "@shift/geo"; +import type { AnchorId, PointId } from "@shift/types"; import type { BoundingRectEdge } from "./cursor"; import type { CornerHandle } from "@/types/boundingBox"; import type { Behavior } from "../core/Behavior"; @@ -40,7 +41,6 @@ export interface RotateDrag { center: Point2D; startAngle: number; currentAngle: number; - snappedAngle?: number; } export interface BendDrag { diff --git a/apps/desktop/src/renderer/src/lib/tools/select/utils.ts b/apps/desktop/src/renderer/src/lib/tools/select/utils.ts index 51713e92..ca338418 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/utils.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/utils.ts @@ -1,4 +1,4 @@ -import type { Point2D, Rect2D } from "@shift/types"; +import type { Point2D, Rect2D } from "@shift/geo"; import { Vec2 } from "@shift/geo"; export function normalizeRect(start: Point2D, current: Point2D): Rect2D { diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts index e0d42a48..57247958 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts @@ -1,10 +1,12 @@ -import type { Point2D, Rect2D } from "@shift/types"; +import type { Point2D, Rect2D } from "@shift/geo"; import { BaseTool, type ToolName, type ToolEvent, defineStateDiagram } from "../core"; import type { ShapeState } from "./types"; import { ShapeReadyBehavior, ShapeDraggingBehavior } from "./behaviors"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; +import { DrawRectangleCommand } from "@/lib/commands/primitives"; export class Shape extends BaseTool { + /** @knipclassignore — declarative state spec for tool docs/debugging. */ static stateSpec = defineStateDiagram({ states: ["idle", "ready", "dragging"], initial: "idle", @@ -74,24 +76,6 @@ export class Shape extends BaseTool { const rect = this.getRect(state); if (Math.abs(rect.width) < 3 || Math.abs(rect.height) < 3) return; - const glyph = this.editor.glyph.peek(); - if (!glyph) return; - - this.batch("Draw Rectangle", () => { - const contourId = glyph.addContour(); - - const edit = (x: number, y: number) => ({ - x, - y, - pointType: "onCurve" as const, - smooth: false, - }); - - glyph.addPointToContour(contourId, edit(rect.x, rect.y)); - glyph.addPointToContour(contourId, edit(rect.x + rect.width, rect.y)); - glyph.addPointToContour(contourId, edit(rect.x + rect.width, rect.y + rect.height)); - glyph.addPointToContour(contourId, edit(rect.x, rect.y + rect.height)); - glyph.closeContour(); - }); + this.editor.commandHistory.execute(new DrawRectangleCommand(rect)); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/types.ts b/apps/desktop/src/renderer/src/lib/tools/shape/types.ts index f98939a6..ed67a592 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/types.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/types.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Behavior } from "../core/Behavior"; export type ShapeState = diff --git a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts index b53634a9..448a3bd4 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -24,14 +24,14 @@ export class TextTool extends BaseTool { // the main glyph the user opened from the grid. Keying on activeGlyph // here would silently switch to a fresh per-active-glyph run when the // user toggles tools mid-slot-edit, wiping the run they were in. - const owner = this.editor.getGlyphHandle(); + const owner = this.editor.rootGlyphHandle; if (!owner) { this.state = { type: "typing" }; this.editor.setPreviewMode(true); return; } - const ownerName = owner.glyphName; + const ownerName = owner.name; const run = this.editor.textRuns.switchTo(ownerName); run.seed(glyphCell(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); run.interaction.suspend(); diff --git a/apps/desktop/src/renderer/src/lib/transform/Alignment.test.ts b/apps/desktop/src/renderer/src/lib/transform/Alignment.test.ts index 3d1083f6..eb2e8c03 100644 --- a/apps/desktop/src/renderer/src/lib/transform/Alignment.test.ts +++ b/apps/desktop/src/renderer/src/lib/transform/Alignment.test.ts @@ -2,10 +2,14 @@ import { describe, it, expect } from "vitest"; import { Alignment } from "./Alignment"; import { Bounds } from "@shift/geo"; import type { PointId } from "@shift/types"; -import { expectAt } from "@/testing"; -import type { PointPosition } from "./PointPosition"; -function createPoint(id: number, x: number, y: number): PointPosition { +interface TestPosition { + readonly id: PointId; + readonly x: number; + readonly y: number; +} + +function createPoint(id: number, x: number, y: number): TestPosition { return { id: `0:${id}` as PointId, x, y }; } @@ -59,8 +63,8 @@ describe("Alignment", () => { it("preserves point IDs", () => { const points = [createPoint(1, 100, 150), createPoint(2, 150, 100)]; const aligned = Alignment.alignPoints(points, "left", bounds); - expect(expectAt(aligned, 0).id).toBe(expectAt(points, 0).id); - expect(expectAt(aligned, 1).id).toBe(expectAt(points, 1).id); + expect(aligned[0]?.id).toBe(points[0]?.id); + expect(aligned[1]?.id).toBe(points[1]?.id); }); }); @@ -70,9 +74,9 @@ describe("Alignment", () => { const distributed = Alignment.distributePoints(points, "horizontal"); const sorted = distributed.sort((a, b) => a.x - b.x); - expect(expectAt(sorted, 0).x).toBe(100); - expect(expectAt(sorted, 1).x).toBe(200); - expect(expectAt(sorted, 2).x).toBe(300); + expect(sorted[0]?.x).toBe(100); + expect(sorted[1]?.x).toBe(200); + expect(sorted[2]?.x).toBe(300); }); it("distributes points vertically with equal spacing", () => { @@ -80,9 +84,9 @@ describe("Alignment", () => { const distributed = Alignment.distributePoints(points, "vertical"); const sorted = distributed.sort((a, b) => a.y - b.y); - expect(expectAt(sorted, 0).y).toBe(100); - expect(expectAt(sorted, 1).y).toBe(250); - expect(expectAt(sorted, 2).y).toBe(400); + expect(sorted[0]?.y).toBe(100); + expect(sorted[1]?.y).toBe(250); + expect(sorted[2]?.y).toBe(400); }); it("keeps first and last points in place horizontally", () => { @@ -111,10 +115,10 @@ describe("Alignment", () => { const distributed = Alignment.distributePoints(points, "horizontal"); const sorted = distributed.sort((a, b) => a.x - b.x); - expect(expectAt(sorted, 0).x).toBe(0); - expect(expectAt(sorted, 1).x).toBeCloseTo(100); - expect(expectAt(sorted, 2).x).toBeCloseTo(200); - expect(expectAt(sorted, 3).x).toBe(300); + expect(sorted[0]?.x).toBe(0); + expect(sorted[1]?.x).toBeCloseTo(100); + expect(sorted[2]?.x).toBeCloseTo(200); + expect(sorted[3]?.x).toBe(300); }); it("preserves point IDs during distribution", () => { diff --git a/apps/desktop/src/renderer/src/lib/transform/Alignment.ts b/apps/desktop/src/renderer/src/lib/transform/Alignment.ts index 93cba41a..8fbbd125 100644 --- a/apps/desktop/src/renderer/src/lib/transform/Alignment.ts +++ b/apps/desktop/src/renderer/src/lib/transform/Alignment.ts @@ -1,13 +1,14 @@ import { Bounds } from "@shift/geo"; import type { AlignmentType, DistributeType } from "./types"; -import type { PointPosition } from "./PointPosition"; + +type Coordinate = { readonly x: number; readonly y: number }; export const Alignment = { - alignPoints( - points: readonly PointPosition[], + alignPoints( + points: readonly T[], alignment: AlignmentType, bounds: Bounds, - ): PointPosition[] { + ): T[] { if (points.length === 0) return []; const center = Bounds.center(bounds); @@ -27,7 +28,7 @@ export const Alignment = { } }, - distributePoints(points: readonly PointPosition[], type: DistributeType): PointPosition[] { + distributePoints(points: readonly T[], type: DistributeType): T[] { if (points.length < 3) return [...points]; const sorted = [...points].sort((a, b) => (type === "horizontal" ? a.x - b.x : a.y - b.y)); diff --git a/apps/desktop/src/renderer/src/lib/transform/PointPosition.ts b/apps/desktop/src/renderer/src/lib/transform/PointPosition.ts deleted file mode 100644 index 3a6c9e24..00000000 --- a/apps/desktop/src/renderer/src/lib/transform/PointPosition.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { PointId } from "@shift/types"; - -/** - * Internal helper shape for geometry transforms that need stable point identity - * plus absolute position. Keep this local to transform/model internals rather - * than exporting it as a broader app-level concept. - */ -export interface PointPosition { - readonly id: PointId; - readonly x: number; - readonly y: number; -} diff --git a/apps/desktop/src/renderer/src/lib/transform/Transform.ts b/apps/desktop/src/renderer/src/lib/transform/Transform.ts index de9b7fbd..25e3d1f1 100644 --- a/apps/desktop/src/renderer/src/lib/transform/Transform.ts +++ b/apps/desktop/src/renderer/src/lib/transform/Transform.ts @@ -19,10 +19,10 @@ * ``` */ -import { Mat, type MatModel } from "@shift/geo"; -import type { Point2D } from "@shift/types"; +import { Mat, type MatModel, type Point2D } from "@shift/geo"; import type { ReflectAxis } from "./types"; -import type { PointPosition } from "./PointPosition"; + +type Coordinate = { readonly x: number; readonly y: number }; /** * Pure transformation functions for geometry manipulation. @@ -36,7 +36,7 @@ export const Transform = { * @param origin - Center of rotation * @returns New array of transformed points (original unchanged) */ - rotatePoints(points: readonly PointPosition[], angle: number, origin: Point2D): PointPosition[] { + rotatePoints(points: readonly T[], angle: number, origin: Point2D): T[] { return Transform.applyMatrix(points, Mat.Rotate(angle), origin); }, @@ -49,12 +49,12 @@ export const Transform = { * @param origin - Center of scaling * @returns New array of transformed points (original unchanged) */ - scalePoints( - points: readonly PointPosition[], + scalePoints( + points: readonly T[], sx: number, sy: number, origin: Point2D, - ): PointPosition[] { + ): T[] { return Transform.applyMatrix(points, Mat.Scale(sx, sy), origin); }, @@ -66,11 +66,11 @@ export const Transform = { * @param origin - Point the axis passes through * @returns New array of transformed points (original unchanged) */ - reflectPoints( - points: readonly PointPosition[], + reflectPoints( + points: readonly T[], axis: ReflectAxis, origin: Point2D, - ): PointPosition[] { + ): T[] { const matrix = axis === "horizontal" ? Mat.ReflectHorizontal() @@ -89,11 +89,11 @@ export const Transform = { * @param origin - Center of transformation (default: {0, 0}) * @returns New array of transformed points (original unchanged) */ - applyMatrix( - points: readonly PointPosition[], + applyMatrix( + points: readonly T[], matrix: MatModel, origin: Point2D = { x: 0, y: 0 }, - ): PointPosition[] { + ): T[] { // Build composite: Translate(-origin) → Matrix → Translate(origin) // Matrix multiplication order (right to left): fromOrigin × matrix × toOrigin const toOrigin = Mat.Translate(-origin.x, -origin.y); @@ -102,7 +102,7 @@ export const Transform = { return points.map((p) => { const transformed = Mat.applyToPoint(composite, p); - return { id: p.id, x: transformed.x, y: transformed.y }; + return { ...p, x: transformed.x, y: transformed.y }; }); }, @@ -120,42 +120,42 @@ export const Transform = { /** * Rotate points by 90 degrees counter-clockwise. */ - rotate90CCW(points: readonly PointPosition[], origin: Point2D): PointPosition[] { + rotate90CCW(points: readonly T[], origin: Point2D): T[] { return Transform.rotatePoints(points, Math.PI / 2, origin); }, /** * Rotate points by 90 degrees clockwise. */ - rotate90CW(points: readonly PointPosition[], origin: Point2D): PointPosition[] { + rotate90CW(points: readonly T[], origin: Point2D): T[] { return Transform.rotatePoints(points, -Math.PI / 2, origin); }, /** * Rotate points by 180 degrees. */ - rotate180(points: readonly PointPosition[], origin: Point2D): PointPosition[] { + rotate180(points: readonly T[], origin: Point2D): T[] { return Transform.rotatePoints(points, Math.PI, origin); }, /** * Scale uniformly (same factor for X and Y). */ - scaleUniform(points: readonly PointPosition[], factor: number, origin: Point2D): PointPosition[] { + scaleUniform(points: readonly T[], factor: number, origin: Point2D): T[] { return Transform.scalePoints(points, factor, factor, origin); }, /** * Flip horizontally (mirror across horizontal axis through origin). */ - flipHorizontal(points: readonly PointPosition[], origin: Point2D): PointPosition[] { + flipHorizontal(points: readonly T[], origin: Point2D): T[] { return Transform.reflectPoints(points, "horizontal", origin); }, /** * Flip vertically (mirror across vertical axis through origin). */ - flipVertical(points: readonly PointPosition[], origin: Point2D): PointPosition[] { + flipVertical(points: readonly T[], origin: Point2D): T[] { return Transform.reflectPoints(points, "vertical", origin); }, } as const; diff --git a/apps/desktop/src/renderer/src/lib/transform/anchor.ts b/apps/desktop/src/renderer/src/lib/transform/anchor.ts index cff1cae2..e6559186 100644 --- a/apps/desktop/src/renderer/src/lib/transform/anchor.ts +++ b/apps/desktop/src/renderer/src/lib/transform/anchor.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import { Bounds } from "@shift/geo"; import type { AnchorPosition } from "@/components/editor/sidebar-right/TransformGrid"; diff --git a/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md index f7f002ea..9ea7831b 100644 --- a/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md @@ -15,7 +15,7 @@ Pure geometry transformation system for rotating, scaling, reflecting, aligning, ``` transform/ Transform.ts — Pure transform functions (rotate, scale, reflect, applyMatrix) - Alignment.ts — Point alignment (snap to edge/center) and distribution + Alignment.ts — Point alignment (edge/center) and distribution SelectionBounds.ts — Segment-aware bounding box that accounts for bezier curves anchor.ts — Maps a 9-position anchor grid to a concrete Point2D on bounds zoomFromWheel.ts — Converts wheel deltaY into a zoom multiplier @@ -25,7 +25,7 @@ transform/ ## Key Types -- `PointPosition` (internal) -- `{ id: PointId; x: number; y: number }`. Local helper shape used inside transform/model internals for stable point identity plus absolute position. +- Coordinate-bearing items -- transform helpers accept any object with `x` and `y` and preserve its other fields. - `ReflectAxis` -- `"horizontal" | "vertical" | { angle: number }`. Named axes or arbitrary angle for reflection. - `AlignmentType` -- `"left" | "center-h" | "right" | "top" | "center-v" | "bottom"`. Edge or center to align against. - `DistributeType` -- `"horizontal" | "vertical"`. Axis along which to space points evenly. @@ -38,7 +38,7 @@ transform/ ### Pure functions layer -`Transform` is a namespace object with pure functions. All three geometric operations -- `rotatePoints`, `scalePoints`, `reflectPoints` -- build a `MatModel` via `Mat` helpers and pass it to `applyMatrix`. These helpers operate on a small internal `PointPosition` shape rather than exporting transform-specific point vocabulary into the wider app. `applyMatrix` constructs the composite `Translate(+origin) * Matrix * Translate(-origin)` so every transform pivots around the caller-supplied origin. +`Transform` is a namespace object with pure functions. All three geometric operations -- `rotatePoints`, `scalePoints`, `reflectPoints` -- build a `MatModel` via `Mat` helpers and pass it to `applyMatrix`. These helpers operate on generic coordinate-bearing objects and preserve metadata such as `kind` and `id`. `applyMatrix` constructs the composite `Translate(+origin) * Matrix * Translate(-origin)` so every transform pivots around the caller-supplied origin. The `matrices` namespace exposes the raw `Mat` builders (`Mat.Rotate`, `Mat.Scale`, etc.) for callers that need to compose custom transforms. @@ -51,7 +51,7 @@ Undo/redo is handled by command classes in `commands/transform/`, re-exported th ### Alignment -`Alignment.alignPoints` snaps every point to one edge or center of the selection's own bounding box (computed via `Bounds.fromPoints`). `Alignment.distributePoints` sorts points along an axis and spaces the interior ones equally between the two extremes. +`Alignment.alignPoints` moves every point to one edge or center of the selection's own bounding box (computed via `Bounds.fromPoints`). `Alignment.distributePoints` sorts points along an axis and spaces the interior ones equally between the two extremes. ### Selection bounds diff --git a/apps/desktop/src/renderer/src/lib/variation/location.ts b/apps/desktop/src/renderer/src/lib/variation/location.ts new file mode 100644 index 00000000..e2956420 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/variation/location.ts @@ -0,0 +1,56 @@ +import type { Axis, Location } from "@shift/types"; +import type { AxisLocation, AxisTag } from "@/types/variation"; + +export function axisTag(tag: string): AxisTag { + return tag as AxisTag; +} + +export function emptyAxisLocation(): AxisLocation { + return new Map(); +} + +export function axisLocationFromRecord(values: Readonly>): AxisLocation { + return new Map(Object.entries(values).map(([tag, value]) => [axisTag(tag), value])); +} + +export function axisLocationFromLocation(location: Location): AxisLocation { + return axisLocationFromRecord(location.values); +} + +export function defaultAxisLocation(axes: readonly Axis[]): AxisLocation { + return new Map(axes.map((axis) => [axisTag(axis.tag), axis.default])); +} + +export function axisValue(location: AxisLocation, axis: Axis): number { + return location.get(axisTag(axis.tag)) ?? axis.default; +} + +export function withAxisValue(location: AxisLocation, axis: Axis, value: number): AxisLocation { + const next = new Map(location); + next.set(axisTag(axis.tag), value); + return next; +} + +export function axisLocationsEqual( + left: AxisLocation, + right: AxisLocation, + axes: readonly Axis[], + tolerance = 1e-6, +): boolean { + return axes.every( + (axis) => Math.abs(axisValue(left, axis) - axisValue(right, axis)) <= tolerance, + ); +} + +export function axisLocationDistanceSquared( + left: AxisLocation, + right: AxisLocation, + axes: readonly Axis[], +): number { + let total = 0; + for (const axis of axes) { + const delta = axisValue(left, axis) - axisValue(right, axis); + total += delta * delta; + } + return total; +} diff --git a/apps/desktop/src/renderer/src/perf/drawing.bench.ts b/apps/desktop/src/renderer/src/perf/drawing.bench.ts index 9e006281..f3809e40 100644 --- a/apps/desktop/src/renderer/src/perf/drawing.bench.ts +++ b/apps/desktop/src/renderer/src/perf/drawing.bench.ts @@ -22,7 +22,7 @@ pm50k.editor.selectTool("select"); describe("pen tool — rapid point placement", () => { bench("place 100 points sequentially", () => { const editor = new TestEditor(); - editor.startSession({ glyphName: "pen-bench" }); + editor.startSession({ name: "pen-bench" }); editor.selectTool("pen"); for (let i = 0; i < 100; i++) { editor.click(i * 10, i * 5); diff --git a/apps/desktop/src/renderer/src/perf/interaction.bench.ts b/apps/desktop/src/renderer/src/perf/interaction.bench.ts index cf8dcb0d..2c7c45bd 100644 --- a/apps/desktop/src/renderer/src/perf/interaction.bench.ts +++ b/apps/desktop/src/renderer/src/perf/interaction.bench.ts @@ -1,13 +1,13 @@ /** * Full-pipeline interaction benchmarks — simulate real drag operations - * through the tool system (hit test -> state machine -> snap -> draft). + * through the tool system (hit test -> state machine -> draft). * * These measure what actually happens during a user drag: - * - pointerDown: hit-test + selection + draft creation + snap session - * - pointerMove (per frame): snap resolution + build updates + draft.setPositions - * - pointerUp: draft.finish + undo recording + * - pointerDown: hit-test + selection + draft creation + * - pointerMove (per frame): draft.preview* + * - pointerUp: draft.commit + undo recording * - * Contrast with pointManipulation.bench.ts which calls createDraft/setPositions + * Contrast with pointManipulation.bench.ts which calls beginSourceEditDraft/previewPositions * directly and skips the tool overhead. */ diff --git a/apps/desktop/src/renderer/src/perf/napiBoundary.bench.ts b/apps/desktop/src/renderer/src/perf/napiBoundary.bench.ts index 4fe4c061..abd161f4 100644 --- a/apps/desktop/src/renderer/src/perf/napiBoundary.bench.ts +++ b/apps/desktop/src/renderer/src/perf/napiBoundary.bench.ts @@ -2,10 +2,7 @@ * NAPI boundary benchmarks — measure the cost of crossing JS/Rust. * * Key scenarios: - * - bridge.sync(positionUpdates) — Float64Array fast path for position-only updates - * - bridge.sync(snapshot) — full JSON round-trip for structural changes - * - bridge.setNodePositions — individual struct marshaling - * - bridge.getSnapshot — reading full glyph state back from Rust + * - layer.setPositions — JS patch + native position sync */ import { bench, describe } from "vitest"; @@ -23,33 +20,14 @@ const marks = [ for (const { label, pm } of marks) { describe(`NAPI boundary — ${label} points`, () => { - bench("bridge.sync — position updates (all points)", () => { + bench("layer.setPositions — all points", () => { const updates = buildPositionUpdates(pm.pointIds, 1, 1); - pm.editor.bridge.sync(updates); + pm.editor.activeGlyphSource!.setPositions(updates); }); - bench("bridge.sync — position updates (single point)", () => { + bench("layer.setPositions — single point", () => { const updates = buildPositionUpdates([pm.pointIds[0]], 1, 1); - pm.editor.bridge.sync(updates); - }); - - bench("bridge.sync — full snapshot round-trip", () => { - const snapshot = pm.editor.bridge.getEditingSnapshot()!; - pm.editor.bridge.sync(snapshot); - }); - - bench("bridge.getSnapshot", () => { - pm.editor.bridge.getEditingSnapshot(); - }); - - bench("bridge.setNodePositions — all points", () => { - const updates = buildPositionUpdates(pm.pointIds, 1, 1); - pm.editor.bridge.setNodePositions(updates); - }); - - bench("bridge.setNodePositions — single point", () => { - const updates = buildPositionUpdates([pm.pointIds[0]], 1, 1); - pm.editor.bridge.setNodePositions(updates); + pm.editor.activeGlyphSource!.setPositions(updates); }); }); } diff --git a/apps/desktop/src/renderer/src/perf/pointManipulation.bench.ts b/apps/desktop/src/renderer/src/perf/pointManipulation.bench.ts index bfa2d538..38db4340 100644 --- a/apps/desktop/src/renderer/src/perf/pointManipulation.bench.ts +++ b/apps/desktop/src/renderer/src/perf/pointManipulation.bench.ts @@ -23,38 +23,38 @@ const marks = [ for (const { label, pm } of marks) { describe(`point manipulation — ${label} points`, () => { - bench("draft.setPositions — single point", () => { - const draft = pm.editor.createDraft(); + bench("draft.previewPositions — single point", () => { + const draft = pm.editor.beginSourceEditDraft({ points: [pm.pointIds[0]] }); const updates = buildPositionUpdates([pm.pointIds[0]], 1, 1); - draft.setPositions(updates); + draft.previewPositions(updates); draft.discard(); }); - bench("draft.setPositions — all points", () => { - const draft = pm.editor.createDraft(); + bench("draft.previewPositions — all points", () => { + const draft = pm.editor.beginSourceEditDraft({ points: pm.pointIds }); const updates = buildPositionUpdates(pm.pointIds, 1, 1); - draft.setPositions(updates); + draft.previewPositions(updates); draft.discard(); }); - bench("draft.finish — single point", () => { - const draft = pm.editor.createDraft(); + bench("draft.commit — single point", () => { + const draft = pm.editor.beginSourceEditDraft({ points: [pm.pointIds[0]] }); const updates = buildPositionUpdates([pm.pointIds[0]], 1, 1); - draft.setPositions(updates); - draft.finish("bench move"); + draft.previewPositions(updates); + draft.commit("bench move"); }); - bench("draft.finish — all points", () => { - const draft = pm.editor.createDraft(); + bench("draft.commit — all points", () => { + const draft = pm.editor.beginSourceEditDraft({ points: pm.pointIds }); const updates = buildPositionUpdates(pm.pointIds, 1, 1); - draft.setPositions(updates); - draft.finish("bench move"); + draft.previewPositions(updates); + draft.commit("bench move"); }); bench("draft.discard — after all-points update", () => { - const draft = pm.editor.createDraft(); + const draft = pm.editor.beginSourceEditDraft({ points: pm.pointIds }); const updates = buildPositionUpdates(pm.pointIds, 5, 5); - draft.setPositions(updates); + draft.previewPositions(updates); draft.discard(); }); diff --git a/apps/desktop/src/renderer/src/perf/rendering.bench.ts b/apps/desktop/src/renderer/src/perf/rendering.bench.ts index 416ec901..f1a74af8 100644 --- a/apps/desktop/src/renderer/src/perf/rendering.bench.ts +++ b/apps/desktop/src/renderer/src/perf/rendering.bench.ts @@ -8,7 +8,7 @@ * Key scenarios: * - renderToolScene — glyph outline + handles + control lines * - renderToolBackground — guides + bounding box - * - renderOverlay — bounding box handles + snap lines + * - renderOverlay — bounding box handles */ import { bench, describe } from "vitest"; diff --git a/apps/desktop/src/renderer/src/persistence/kernel.ts b/apps/desktop/src/renderer/src/persistence/kernel.ts index 987db13e..dee98cb6 100644 --- a/apps/desktop/src/renderer/src/persistence/kernel.ts +++ b/apps/desktop/src/renderer/src/persistence/kernel.ts @@ -1,5 +1,5 @@ import type { Editor } from "@/lib/editor/Editor"; -import { effect, type Effect } from "@/lib/reactive/signal"; +import { effect, type Effect } from "@/lib/signals/signal"; import { PersistedRootSchema } from "@shift/validation"; import type { PersistenceModule } from "./module"; import { toolStateAppModule, toolStateDocumentModule } from "./modules/toolState"; diff --git a/apps/desktop/src/renderer/src/persistence/types.ts b/apps/desktop/src/renderer/src/persistence/types.ts index 85675c6d..cd4c33da 100644 --- a/apps/desktop/src/renderer/src/persistence/types.ts +++ b/apps/desktop/src/renderer/src/persistence/types.ts @@ -4,7 +4,6 @@ import type { PersistedRoot, PersistenceRegistry, PersistedTextRun, - UserPreferences, TextRunModule, } from "@shift/validation"; @@ -17,7 +16,6 @@ export type { PersistedDocument, PersistenceRegistry, PersistedRoot, - UserPreferences, TextRunModule, PersistedTextRun, }; diff --git a/apps/desktop/src/renderer/src/store/glyphInfo.ts b/apps/desktop/src/renderer/src/store/glyphInfo.ts deleted file mode 100644 index ede52e6a..00000000 --- a/apps/desktop/src/renderer/src/store/glyphInfo.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { GlyphInfo, defaultResources } from "@shift/glyph-info"; - -let instance: GlyphInfo | null = null; - -export function getGlyphInfo(): GlyphInfo { - if (!instance) instance = new GlyphInfo(defaultResources); - return instance; -} diff --git a/apps/desktop/src/renderer/src/store/store.ts b/apps/desktop/src/renderer/src/store/store.ts index addeafac..6b13dd16 100644 --- a/apps/desktop/src/renderer/src/store/store.ts +++ b/apps/desktop/src/renderer/src/store/store.ts @@ -1,9 +1,10 @@ import { Editor } from "@/lib/editor/Editor"; -import { NativeBridge } from "@/bridge/NativeBridge"; import { electronSystemClipboard } from "@/lib/clipboard"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { create } from "zustand"; import type { StoreApi } from "zustand"; +import type { ShiftBridge } from "@shift/bridge"; +import { defaultResources, GlyphInfo } from "@shift/glyph-info"; interface AppState { editor: Editor; @@ -23,9 +24,23 @@ function getFileNameFromPath(path: string | null): string | null { return parts[parts.length - 1] || null; } +function createShiftBridge(): ShiftBridge { + if (!window.shiftBridge) { + throw Error("native bridge has not been exposed by preload"); + } + + return window.shiftBridge; +} + +let instance: GlyphInfo | null = null; +export function getGlyphInfo(): GlyphInfo { + if (!instance) instance = new GlyphInfo(defaultResources); + return instance; +} + const createStore = (set: StoreApi["setState"]): AppState => { const editor = new Editor({ - bridge: new NativeBridge(), + bridge: createShiftBridge(), clipboard: electronSystemClipboard, }); registerBuiltInTools(editor); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 33e61e7e..4a9767aa 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -9,15 +9,16 @@ * expect(editor.pointCount).toBe(1); */ -import type { GlyphHandle } from "@shared/bridge/FontEngineAPI"; -import type { Point2D, PointId, GlyphSnapshot } from "@shift/types"; -import type { Glyph } from "@/lib/model/Glyph"; -import { Glyphs } from "@shift/font"; +import type { GlyphHandle } from "@shared/bridge/BridgeApi"; +import type { PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { Glyph, GlyphSource } from "@/lib/model/Glyph"; import { Editor } from "@/lib/editor/Editor"; import type { ToolName } from "@/lib/tools/core"; import { registerBuiltInTools } from "@/lib/tools/tools"; -import { createBridge } from "./engine"; +import { createBridge } from "@shift/bridge"; import type { SystemClipboard } from "@/lib/clipboard"; +import { MUTATORSANS_DESIGNSPACE } from "./fixtures"; const DEFAULT_MODIFIERS = { shiftKey: false, altKey: false, metaKey: false }; @@ -50,9 +51,17 @@ export class TestEditor extends Editor { return this.#clipboard.buffer; } - startSession(handle: GlyphHandle = { glyphName: "A", unicode: 65 }): this { - this.setGlyphHandle(handle); - this.openGlyph(handle); + startSession(handle: GlyphHandle = { name: "A", unicode: 65 }): this { + if (!this.font.loaded) { + this.loadFont(MUTATORSANS_DESIGNSPACE); + } + + const source = this.font.defaultSource(); + if (!source) throw new Error("TestEditor needs a loaded font source"); + const glyphSource = this.editGlyphSource(handle, source.id); + if (glyphSource) { + glyphSource.removePoints(glyphSource.allPoints.map((point) => point.id)); + } return this; } @@ -106,27 +115,25 @@ export class TestEditor extends Editor { return this; } - get snapshot(): GlyphSnapshot | null { - return this.bridge.getEditingSnapshot(); - } - get currentGlyph(): Glyph | null { return this.glyph.peek(); } + get currentGlyphSource(): GlyphSource | null { + return this.activeGlyphSource; + } + get pointCount(): number { - const glyph = this.currentGlyph; - if (!glyph) return 0; - return Glyphs.getAllPoints(glyph).length; + return this.currentGlyphSource?.allPoints.length ?? 0; } getPointPosition(pointId: PointId): Point2D | null { - const glyph = this.currentGlyph; - if (!glyph) return null; + const source = this.currentGlyphSource; + if (!source) return null; - const found = Glyphs.findPoint(glyph, pointId); - if (!found) return null; + const point = source.point(pointId); + if (!point) return null; - return { x: found.point.x, y: found.point.y }; + return { x: point.x, y: point.y }; } } diff --git a/apps/desktop/src/renderer/src/testing/coordinates.ts b/apps/desktop/src/renderer/src/testing/coordinates.ts index 8469d7f2..81d3a555 100644 --- a/apps/desktop/src/renderer/src/testing/coordinates.ts +++ b/apps/desktop/src/renderer/src/testing/coordinates.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; import type { Coordinates } from "@/types/coordinates"; /** For tests: build Coordinates with the same point in all three spaces. */ diff --git a/apps/desktop/src/renderer/src/testing/engine.ts b/apps/desktop/src/renderer/src/testing/engine.ts deleted file mode 100644 index eff4a587..00000000 --- a/apps/desktop/src/renderer/src/testing/engine.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GlyphSnapshot, PointSnapshot } from "@shift/types"; -import { NativeBridge } from "@/bridge/NativeBridge"; -import { Glyphs } from "@shift/font"; - -export function createBridge(): NativeBridge { - const { FontEngine: NativeFontEngine } = require("shift-node"); - return new NativeBridge(new NativeFontEngine()); -} - -export function getAllPoints(snapshot: GlyphSnapshot | null): PointSnapshot[] { - if (!snapshot) return []; - return Glyphs.getAllPoints(snapshot); -} - -export function getPointCount(snapshot: GlyphSnapshot | null): number { - if (!snapshot) return 0; - return Glyphs.getAllPoints(snapshot).length; -} diff --git a/apps/desktop/src/renderer/src/testing/index.ts b/apps/desktop/src/renderer/src/testing/index.ts index c66490f1..dc9ecb96 100644 --- a/apps/desktop/src/renderer/src/testing/index.ts +++ b/apps/desktop/src/renderer/src/testing/index.ts @@ -1,4 +1,4 @@ -export { createBridge, getAllPoints, getPointCount } from "./engine"; +export { createBridge } from "@shift/bridge"; export { TestEditor } from "./TestEditor"; export { makeTestCoordinates } from "./coordinates"; export { expectDefined, expectAt } from "./asserts"; diff --git a/apps/desktop/src/renderer/src/testing/pointMark.ts b/apps/desktop/src/renderer/src/testing/pointMark.ts index 71f684c1..d7f65790 100644 --- a/apps/desktop/src/renderer/src/testing/pointMark.ts +++ b/apps/desktop/src/renderer/src/testing/pointMark.ts @@ -8,8 +8,7 @@ import type { PointId } from "@shift/types"; import type { ContourContent, PointContent } from "@/lib/clipboard/types"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; -import { Glyphs } from "@shift/font"; +import type { SourcePositions } from "@/lib/model/Glyph"; import { TestEditor } from "./TestEditor"; /** @@ -109,25 +108,28 @@ export interface PointMarkEditor { */ export function createPointMark(scale: PointScale): PointMarkEditor { const editor = new TestEditor(); - editor.startSession({ glyphName: "bench" }); + editor.startSession({ name: "A", unicode: 65 }); const contours = generateContours(scale); - const result = editor.bridge.pasteContours(contours, 0, 0); + const source = editor.currentGlyphSource; + if (!source) throw new Error("No source after startSession"); - if (!result.success) { - throw new Error(`Failed to create point mark at scale ${scale}`); + source.removePoints(source.allPoints.map((point) => point.id)); + for (const contour of contours) { + const contourId = source.addContour(); + for (const point of contour.points) { + source.addPoint(contourId, point); + } + if (contour.closed) source.closeContour(contourId); } - const glyph = editor.currentGlyph; - if (!glyph) throw new Error("No glyph after pasteContours"); - - const pointIds = Glyphs.getAllPoints(glyph).map((p) => p.id); + const pointIds = source.allPoints.map((p) => p.id); return { editor, pointIds, pointCount: pointIds.length }; } /** - * Build a NodePositionUpdateList that shifts every point by (dx, dy). + * Build a SourcePositions list that shifts every point by (dx, dy). * Pre-computed outside the benchmark loop to isolate the operation under test. */ export function buildPositionUpdates( @@ -136,9 +138,10 @@ export function buildPositionUpdates( dy: number, baseX = 0, baseY = 0, -): NodePositionUpdateList { +): SourcePositions { return pointIds.map((id, i) => ({ - node: { kind: "point" as const, id }, + kind: "point" as const, + id, x: baseX + i + dx, y: baseY + i + dy, })); diff --git a/apps/desktop/src/renderer/src/types/engine.ts b/apps/desktop/src/renderer/src/types/bridge.ts similarity index 50% rename from apps/desktop/src/renderer/src/types/engine.ts rename to apps/desktop/src/renderer/src/types/bridge.ts index f4f0b2cc..39a6b2a6 100644 --- a/apps/desktop/src/renderer/src/types/engine.ts +++ b/apps/desktop/src/renderer/src/types/bridge.ts @@ -1,15 +1,8 @@ -import type { ContourId, GlyphSnapshot, PointId, PointType } from "@shift/types"; - -export interface CommandResult { - snapshot: GlyphSnapshot; - affectedPointIds: PointId[]; -} +import type { ContourId, PointId, PointType } from "@shift/types"; export interface PasteResult { - success: boolean; createdPointIds: PointId[]; createdContourIds: ContourId[]; - error?: string; } export interface PointEdit { diff --git a/apps/desktop/src/renderer/src/types/coordinates.ts b/apps/desktop/src/renderer/src/types/coordinates.ts index a1b7ad4b..65e7e91f 100644 --- a/apps/desktop/src/renderer/src/types/coordinates.ts +++ b/apps/desktop/src/renderer/src/types/coordinates.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; /** * A point in all three coordinate spaces: screen (canvas pixels), scene (UPM diff --git a/apps/desktop/src/renderer/src/types/draft.ts b/apps/desktop/src/renderer/src/types/draft.ts deleted file mode 100644 index 7d22f1c5..00000000 --- a/apps/desktop/src/renderer/src/types/draft.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { GlyphSnapshot } from "@shift/types"; -import type { NodePositionUpdateList } from "@/types/positionUpdate"; - -export interface GlyphDraft { - readonly base: GlyphSnapshot; - setPositions(updates: NodePositionUpdateList): void; - finish(label: string): void; - discard(): void; -} diff --git a/apps/desktop/src/renderer/src/types/editor.ts b/apps/desktop/src/renderer/src/types/editor.ts index ef39fbf0..091f2464 100644 --- a/apps/desktop/src/renderer/src/types/editor.ts +++ b/apps/desktop/src/renderer/src/types/editor.ts @@ -39,15 +39,6 @@ export interface TemporaryToolOptions { onReturn?: () => void; } -export interface SnapPreferences { - enabled: boolean; - angle: boolean; - metrics: boolean; - pointToPoint: boolean; - angleIncrementDeg: number; - pointRadiusPx: number; -} - export interface ToolSwitchHandler { requestTemporary: (toolId: ToolName, options?: TemporaryToolOptions) => void; returnFromTemporary: () => void; diff --git a/apps/desktop/src/renderer/src/types/hitResult.ts b/apps/desktop/src/renderer/src/types/hitResult.ts index 80caa49d..b1f81555 100644 --- a/apps/desktop/src/renderer/src/types/hitResult.ts +++ b/apps/desktop/src/renderer/src/types/hitResult.ts @@ -13,8 +13,10 @@ * * @module */ -import type { Point2D, PointId, ContourId, Point, Contour, AnchorId } from "@shift/types"; -import type { Segment } from "@/lib/model/Segment"; +import type { Point2D } from "@shift/geo"; +import type { AnchorId, ContourId, PointId } from "@shift/types"; +import type { Contour, Point } from "@shift/glyph-state"; +import type { Segment } from "@shift/glyph-state"; import type { SegmentId } from "./indicator"; import type { BoundingBoxHitResult } from "./boundingBox"; diff --git a/apps/desktop/src/renderer/src/types/indicator.ts b/apps/desktop/src/renderer/src/types/indicator.ts index f697f1ba..1dff939b 100644 --- a/apps/desktop/src/renderer/src/types/indicator.ts +++ b/apps/desktop/src/renderer/src/types/indicator.ts @@ -1,20 +1,8 @@ -import type { Point2D, PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; +import type { SegmentId } from "@shift/glyph-state"; -declare const SegmentIdBrand: unique symbol; - -/** - * Branded string that uniquely identifies a curve segment within a glyph. - * Encoded as `"anchor1Id:anchor2Id"` -- the IDs of the two on-curve endpoints - * joined by a colon. The brand prevents accidental use of raw strings where - * a segment ID is expected. - */ -export type SegmentId = string & { - readonly [SegmentIdBrand]: typeof SegmentIdBrand; -}; - -export function asSegmentId(id: string): SegmentId { - return id as SegmentId; -} +export type { SegmentId }; /** * Describes the closest point on a segment to the cursor. diff --git a/apps/desktop/src/renderer/src/types/positionUpdate.ts b/apps/desktop/src/renderer/src/types/positionUpdate.ts deleted file mode 100644 index 2b8165bb..00000000 --- a/apps/desktop/src/renderer/src/types/positionUpdate.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AnchorId, PointId } from "@shift/types"; - -export type NodeRef = - | { kind: "point"; id: PointId } - | { kind: "anchor"; id: AnchorId } - | { kind: "guideline"; id: string }; - -export interface NodePositionUpdate { - node: NodeRef; - x: number; - y: number; -} - -export type NodePositionUpdateList = readonly NodePositionUpdate[]; diff --git a/apps/desktop/src/renderer/src/types/segments.ts b/apps/desktop/src/renderer/src/types/segments.ts deleted file mode 100644 index 9ca6f4f4..00000000 --- a/apps/desktop/src/renderer/src/types/segments.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { PointId } from "@shift/types"; - -/** - * Minimal point interface for segments. - * Compatible with PointSnapshot from Rust. - */ -export interface SegmentPoint { - id: PointId; - x: number; - y: number; - pointType: "onCurve" | "offCurve"; - smooth: boolean; -} - -/** - * A line segment between two on-curve points. - */ -export type LineSegment = { - type: "line"; - points: { - anchor1: SegmentPoint; - anchor2: SegmentPoint; - }; -}; - -/** - * A quadratic bezier curve with one control point. - * Pattern: onCurve → offCurve → onCurve - */ -export type QuadSegment = { - type: "quad"; - points: { - anchor1: SegmentPoint; - control: SegmentPoint; - anchor2: SegmentPoint; - }; -}; - -/** - * A cubic bezier curve with two control points. - * Pattern: onCurve → offCurve → offCurve → onCurve - */ -export type CubicSegment = { - type: "cubic"; - points: { - anchor1: SegmentPoint; - control1: SegmentPoint; - control2: SegmentPoint; - anchor2: SegmentPoint; - }; -}; - -/** - * Raw discriminated data for a segment in a contour — line, quad, or cubic. - * The public-facing API is the `Segment` class in `lib/model/Segment.ts`, - * which wraps this data and provides id-aware operations. - */ -export type SegmentType = LineSegment | QuadSegment | CubicSegment; diff --git a/apps/desktop/src/renderer/src/types/selection.test.ts b/apps/desktop/src/renderer/src/types/selection.test.ts index b871bc41..be83d76c 100644 --- a/apps/desktop/src/renderer/src/types/selection.test.ts +++ b/apps/desktop/src/renderer/src/types/selection.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { Selection } from "@/types/selection"; -import { signal } from "@/lib/reactive/signal"; +import { signal } from "@/lib/signals/signal"; import type { PointId } from "@shift/types"; import type { SegmentId } from "@/types/indicator"; import type { Glyph } from "@/lib/model/Glyph"; diff --git a/apps/desktop/src/renderer/src/types/selection.ts b/apps/desktop/src/renderer/src/types/selection.ts index 8efceb99..a81b4381 100644 --- a/apps/desktop/src/renderer/src/types/selection.ts +++ b/apps/desktop/src/renderer/src/types/selection.ts @@ -1,4 +1,5 @@ -import type { PointId, ContourId, AnchorId, Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { PointId, ContourId, AnchorId } from "@shift/types"; import type { SegmentId } from "./indicator"; import type { Glyph } from "@/lib/model/Glyph"; import type { SelectionMode } from "./editor"; @@ -8,7 +9,7 @@ import { type WritableSignal, type Signal, type ComputedSignal, -} from "@/lib/reactive/signal"; +} from "@/lib/signals/signal"; import { Bounds, type Bounds as BoundsType } from "@shift/geo"; /** Discriminated reference to any selectable entity. */ diff --git a/apps/desktop/src/renderer/src/types/transform.ts b/apps/desktop/src/renderer/src/types/transform.ts index ff272303..26451c31 100644 --- a/apps/desktop/src/renderer/src/types/transform.ts +++ b/apps/desktop/src/renderer/src/types/transform.ts @@ -1,4 +1,4 @@ -import type { Point2D } from "@shift/types"; +import type { Point2D } from "@shift/geo"; /** * Axis or arbitrary angle for reflection transforms. diff --git a/apps/desktop/src/renderer/src/types/variation.ts b/apps/desktop/src/renderer/src/types/variation.ts new file mode 100644 index 00000000..89f900cb --- /dev/null +++ b/apps/desktop/src/renderer/src/types/variation.ts @@ -0,0 +1,4 @@ +declare const AxisTagBrand: unique symbol; + +export type AxisTag = string & { readonly [AxisTagBrand]: true }; +export type AxisLocation = ReadonlyMap; diff --git a/apps/desktop/src/renderer/src/views/Editor.tsx b/apps/desktop/src/renderer/src/views/Editor.tsx index ace39b7b..ef90e061 100644 --- a/apps/desktop/src/renderer/src/views/Editor.tsx +++ b/apps/desktop/src/renderer/src/views/Editor.tsx @@ -9,7 +9,7 @@ import { GlyphFinder } from "@/components/editor/GlyphFinder"; import { EditorView } from "@/components/editor/EditorView"; import { getEditor } from "@/store/store"; import { useFocusZone, ZoneContainer } from "@/context/FocusZoneContext"; -import { useSignalState } from "@/lib/reactive"; +import { useSignalState } from "@/lib/signals"; import { KeyboardRouter } from "@/lib/keyboard"; import { codepointToHex } from "@/lib/utils/unicode"; diff --git a/apps/desktop/src/renderer/src/views/FontInfo.tsx b/apps/desktop/src/renderer/src/views/FontInfo.tsx index 3b5e63ac..e9868f75 100644 --- a/apps/desktop/src/renderer/src/views/FontInfo.tsx +++ b/apps/desktop/src/renderer/src/views/FontInfo.tsx @@ -3,7 +3,9 @@ import { getEditor } from "@/store/store"; export const FontInfo = () => { const editor = getEditor(); - const metrics = editor.font.getMetrics(); + if (!editor.font.loaded) return null; + + const metrics = editor.font.metrics; const metadata = editor.font.metadata; return ( diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index d2295f45..d53aef22 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -10,11 +10,14 @@ export const Landing = () => { const openFont = (filePath: string) => { const editor = getEditor(); + editor.loadFont(filePath); - editor.updateMetricsFromFont(); + setFilePath(filePath); clearDirty(); + documentPersistence.openDocument(filePath); + navigate("/home"); }; @@ -27,14 +30,13 @@ export const Landing = () => { const handleNewFont = () => { const editor = getEditor(); - const name = editor.font.glyphName(65); - const handle = { glyphName: name, unicode: 65 }; - editor.setGlyphHandle(handle); - editor.openGlyph(handle); - editor.font.reset(); + editor.resetFont(); + setFilePath(null); clearDirty(); + documentPersistence.closeDocument(); + navigate("/home"); }; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index ea6fa5bc..0233caa6 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -12,6 +12,7 @@ "skipLibCheck": true, "allowJs": true, "checkJs": true, + "moduleResolution": "bundler", "noUnusedLocals": true, "noUnusedParameters": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts index a4b68a9e..26ecb5cd 100644 --- a/apps/desktop/vite.renderer.config.ts +++ b/apps/desktop/vite.renderer.config.ts @@ -47,7 +47,7 @@ export default defineConfig(async () => { "@shift/ui": path.resolve(packagesDir, "ui/src/index.ts"), "@shift/geo": path.resolve(packagesDir, "geo/src/index.ts"), "@shift/types": path.resolve(packagesDir, "types/src/index.ts"), - "@shift/font": path.resolve(packagesDir, "font/src/index.ts"), + "@shift/glyph-state": path.resolve(packagesDir, "glyph-state/src/index.ts"), "@shift/glyph-info": path.resolve(packagesDir, "glyph-info/src/index.ts"), "@shift/rules": path.resolve(packagesDir, "rules/src/index.ts"), }, @@ -58,7 +58,7 @@ export default defineConfig(async () => { "@shift/ui", "@shift/geo", "@shift/types", - "@shift/font", + "@shift/glyph-state", "@shift/glyph-info", "@shift/rules", ], diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index 2d5e99bd..44bb9f35 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -103,16 +103,8 @@ fn loads_ufo_components_anchors_layers_and_kerning() { assert!(layer_names.contains(&"public.default")); assert!(font.layers().len() >= 2); - assert_eq!( - font.kerning() - .get_kerning("T", "A"), - Some(-75.0) - ); - assert_eq!( - font.kerning() - .get_kerning("V", "A"), - Some(-100.0) - ); + assert_eq!(font.kerning().get_kerning("T", "A"), Some(-75.0)); + assert_eq!(font.kerning().get_kerning("V", "A"), Some(-100.0)); } #[test] @@ -146,16 +138,8 @@ fn loads_glyphs_file_features_kerning_components_and_anchors() { assert!(fea.contains("feature frac")); assert!(fea.contains("feature ordn")); - assert_eq!( - font.kerning() - .get_kerning("A", "V"), - Some(-55.0) - ); - assert_eq!( - font.kerning() - .get_kerning("V", "a"), - Some(-65.0) - ); + assert_eq!(font.kerning().get_kerning("A", "V"), Some(-55.0)); + assert_eq!(font.kerning().get_kerning("V", "a"), Some(-65.0)); let aacute = font.glyph("Aacute").expect("Aacute glyph should exist"); let component_bases: Vec<_> = main_layer(aacute) diff --git a/crates/shift-backends/tests/round_trip/ufo.rs b/crates/shift-backends/tests/round_trip/ufo.rs index d1b39f0a..0f74e990 100644 --- a/crates/shift-backends/tests/round_trip/ufo.rs +++ b/crates/shift-backends/tests/round_trip/ufo.rs @@ -188,16 +188,6 @@ fn preserves_components_anchors_layers_and_kerning() { assert!(reloaded_layer_names.contains(&name)); } - assert_eq!( - reloaded - .kerning() - .get_kerning("T", "A"), - Some(-75.0) - ); - assert_eq!( - reloaded - .kerning() - .get_kerning("V", "A"), - Some(-100.0) - ); + assert_eq!(reloaded.kerning().get_kerning("T", "A"), Some(-75.0)); + assert_eq!(reloaded.kerning().get_kerning("V", "A"), Some(-100.0)); } diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index 68d830c7..36d79995 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -14,6 +14,10 @@ describe("Bridge", () => { bridge = new Bridge(); }); + function defaultSourceId() { + return bridge.getSources()[0].id; + } + it("starts with default committed font metadata", () => { expect(bridge.getMetadata()).toMatchObject({ familyName: "Untitled Font", @@ -35,9 +39,10 @@ describe("Bridge", () => { }); it("commits a new glyph when the edit session ends", () => { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); expect(bridge.hasEditSession()).toBe(true); expect(bridge.getEditingGlyphName()).toBe("A"); + expect(bridge.getEditingSourceId()).toBe(defaultSourceId()); expect(bridge.getEditingUnicode()).toBe(65); bridge.endEditSession(); @@ -48,15 +53,15 @@ describe("Bridge", () => { ]); }); - it("saves the active edit snapshot without ending the session", () => { + it("saves the active edit snapshot without ending the session", async () => { const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-save-")); try { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); const contourId = bridge.addContour().changed.contourIds[0]; bridge.addPoint(contourId, 10, 20, "onCurve", false); const outputPath = join(tempDir, "output.ufo"); - const savedVersion = bridge.saveFont(outputPath); + const savedVersion = await bridge.saveFont(outputPath); expect(savedVersion).toBe(2); expect(bridge.hasEditSession()).toBe(true); @@ -77,11 +82,11 @@ describe("Bridge", () => { it("records the persisted version when an async save completes", async () => { const tempDir = mkdtempSync(join(tmpdir(), "shift-bridge-async-save-")); try { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); bridge.addContour(); const outputPath = join(tempDir, "async-output.ufo"); - const savedVersion = await bridge.saveFontAsync(outputPath); + const savedVersion = await bridge.saveFont(outputPath); expect(savedVersion).toBe(1); expect(bridge.getPersistedVersion()).toBe(1); @@ -93,16 +98,16 @@ describe("Bridge", () => { }); it("rejects starting a second active edit session", () => { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); - expect(() => bridge.startEditSession({ name: "B", unicode: 66 })).toThrow( + expect(() => bridge.startEditSession({ name: "B", unicode: 66 }, defaultSourceId())).toThrow( /edit session already active/i, ); expect(bridge.getEditingGlyphName()).toBe("A"); }); it("adds a point to a contour and returns structure, values, and changed ids", () => { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); const contourChange = bridge.addContour(); const contourId = contourChange.changed.contourIds[0]; @@ -125,7 +130,7 @@ describe("Bridge", () => { }); it("sets point positions through the bulk typed-array hot path", () => { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); const contourId = bridge.addContour().changed.contourIds[0]; const pointId = bridge.addPoint(contourId, 10, 20, "onCurve", false).changed.pointIds[0]; @@ -141,7 +146,7 @@ describe("Bridge", () => { }); it("restores structure and values into the active session", () => { - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); const contourId = bridge.addContour().changed.contourIds[0]; const before = bridge.addPoint(contourId, 10, 20, "onCurve", false); const pointId = before.changed.pointIds[0]; @@ -155,7 +160,7 @@ describe("Bridge", () => { it("surfaces typed bridge errors at the NAPI boundary", () => { expect(() => bridge.addContour()).toThrow(/active edit/i); - bridge.startEditSession({ name: "A", unicode: 65 }); + bridge.startEditSession({ name: "A", unicode: 65 }, defaultSourceId()); expect(() => bridge.addPoint("not-a-contour", 10, 20, "onCurve", false)).toThrow(/contour ID/i); expect(() => bridge.setPositions(new BigUint64Array([1n]), new Float64Array([10]), null, null), diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 85f1bc04..fd67a9a0 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -17,18 +17,20 @@ export declare class Bridge { getMetrics(): NapiFontMetrics getGlyphCount(): number getGlyphs(): Array - getGlyphState(glyphRef: GlyphHandle): NapiGlyphState | null + getGlyphState(glyphHandle: GlyphHandle, sourceId: SourceId): NapiGlyphState | null + getGlyphVariationReport(glyphRef: GlyphHandle): NapiGlyphVariationReport | null + getVariationReports(): Array isVariable(): boolean getAxes(): Array getSources(): Array - startEditSession(glyphRef: GlyphHandle): void - getLiveVersion(): number + startEditSession(glyphHandle: GlyphHandle, sourceId: SourceId): void getPersistedVersion(): number isDirty(): boolean endEditSession(): void hasEditSession(): boolean getEditingUnicode(): Unicode | null getEditingGlyphName(): GlyphName | null + getEditingSourceId(): SourceId | null setXAdvance(width: number): NapiGlyphValueChange translateLayer(dx: number, dy: number): NapiGlyphValueChange addPoint(contourId: ContourId, x: number, y: number, pointType: NapiPointType, smooth: boolean): NapiGlyphStructureChange @@ -52,6 +54,30 @@ export interface GlyphHandle { name: GlyphName unicode?: Unicode } + +export interface NapiGlyphVariationDiagnostic { + glyphName: GlyphName + code: string + severity: string + source?: NapiGlyphVariationDiagnosticSource + message: string +} + +export interface NapiGlyphVariationDiagnosticSource { + id: SourceId + index: number + name: string +} + +export interface NapiGlyphVariationReport { + glyphName: GlyphName + status: string + variationDataAvailable: boolean + masterCount: number + compatibleMasterCount: number + skippedMasterCount: number + diagnostics: Array +} export interface NapiAnchorData { id: AnchorId name?: string diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 7c2db6bc..32606963 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -1,4 +1,4 @@ -use crate::errors::{self, to_napi_error, BridgeError, BridgeResult}; +use crate::errors::{self, BridgeError, BridgeResult}; use crate::input::parse; use napi::bindgen_prelude::*; use napi::{Error, Status}; @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; use shift_backends::{font_loader::FontLoader, ufo::UfoWriter, FontView}; use shift_edit::{ edit_session::{BulkNodePositionUpdates, EditSession}, - interpolation::{build_masters, get_glyph_variation_data}, - BooleanOp, ContourId, Font, Glyph, GlyphLayer, LayerId, PointId, + interpolation::{build_glyph_variation_data, build_masters, GlyphVariationBuild}, + BooleanOp, ContourId, Font, Glyph, GlyphLayer, LayerId, PointId, SourceId, }; use shift_wire::{ bridges::napi::{ @@ -16,8 +16,9 @@ use shift_wire::{ NapiGlyphStructure, NapiGlyphStructureChange, NapiGlyphValueChange, NapiPointType, NapiSource, }, Axis, FontMetadata, FontMetrics, GlyphChangedEntities, GlyphRecord, GlyphState, GlyphStructure, - GlyphStructureChange, GlyphValueChange, Source, + GlyphStructureChange, GlyphValueChange, Location, Source, }; +use std::collections::HashMap; use std::sync::{ atomic::{AtomicU64, Ordering}, Arc, @@ -32,9 +33,41 @@ pub struct GlyphHandle { pub unicode: Option, } +#[napi(object)] +pub struct NapiGlyphVariationDiagnosticSource { + #[napi(ts_type = "SourceId")] + pub id: String, + pub index: u32, + pub name: String, +} + +#[napi(object)] +pub struct NapiGlyphVariationDiagnostic { + #[napi(ts_type = "GlyphName")] + pub glyph_name: String, + pub code: String, + pub severity: String, + pub source: Option, + pub message: String, +} + +#[napi(object)] +pub struct NapiGlyphVariationReport { + #[napi(ts_type = "GlyphName")] + pub glyph_name: String, + pub status: String, + pub variation_data_available: bool, + pub master_count: u32, + pub compatible_master_count: u32, + pub skipped_master_count: u32, + pub diagnostics: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] pub struct DocumentVersion(u64); +const STATIC_DEFAULT_SOURCE_ID: u128 = 0; + impl DocumentVersion { fn next(self) -> Self { Self(self.0 + 1) @@ -190,21 +223,28 @@ impl Task for SaveFontTask { pub struct ActiveEdit { session: EditSession, glyph: Glyph, + source_id: SourceId, layer_id: LayerId, dirty: bool, } impl ActiveEdit { - fn new(session: EditSession, glyph: Glyph, layer_id: LayerId) -> Self { + fn new(session: EditSession, glyph: Glyph, source_id: SourceId, layer_id: LayerId) -> Self { Self { session, glyph, + source_id, layer_id, dirty: false, } } - fn from_glyph(glyph: Glyph, layer_id: LayerId, unicode_hint: Option) -> Self { + fn from_glyph( + glyph: Glyph, + source_id: SourceId, + layer_id: LayerId, + unicode_hint: Option, + ) -> Self { let unicode = glyph.primary_unicode().or(unicode_hint).unwrap_or(0); let layer = glyph .layer(layer_id) @@ -212,7 +252,7 @@ impl ActiveEdit { .unwrap_or_else(|| GlyphLayer::with_width(500.0)); let session = EditSession::new(glyph.name().to_string(), unicode, layer); - Self::new(session, glyph, layer_id) + Self::new(session, glyph, source_id, layer_id) } fn session(&self) -> &EditSession { @@ -231,6 +271,10 @@ impl ActiveEdit { self.dirty } + fn source_id(&self) -> SourceId { + self.source_id + } + fn glyph_with_session_layer(&self) -> Glyph { let mut glyph = self.glyph.clone(); glyph.set_layer(self.layer_id, self.session.layer().clone()); @@ -329,13 +373,57 @@ impl Bridge { } #[napi] - pub fn get_glyph_state(&self, glyph_ref: GlyphHandle) -> Option { + pub fn get_glyph_state( + &self, + glyph_handle: GlyphHandle, + #[napi(ts_arg_type = "SourceId")] source_id: String, + ) -> errors::Result> { + let source_id = parse::(&source_id)?; + let layer_id = self.source_layer_id(source_id)?; + + let glyph = match self.glyph_for_read(&glyph_handle.name) { + Some(glyph) => glyph, + None => return Ok(None), + }; + let layer = match glyph.layer(layer_id) { + Some(layer) => layer, + None => return Ok(None), + }; + + let variation_data = self + .variation_build_for_glyph(&glyph) + .and_then(|(_, build)| build.variation_data); + + Ok(Some(GlyphState::from_layer(layer, variation_data).into())) + } + + #[napi] + pub fn get_glyph_variation_report( + &self, + glyph_ref: GlyphHandle, + ) -> Option { let glyph = self.glyph_for_read(&glyph_ref.name)?; - let layer = glyph.layer(self.font.default_layer_id())?; - let variation_data = build_masters(&self.font, &glyph) - .and_then(|masters| get_glyph_variation_data(&masters, self.font.axes())); + Some(self.variation_report_for_glyph(&glyph_ref.name, &glyph)) + } - Some(GlyphState::from_layer(layer, variation_data).into()) + #[napi] + pub fn get_variation_reports(&self) -> Vec { + let mut glyph_names: Vec = self + .font + .glyphs() + .keys() + .map(|glyph_name| glyph_name.to_string()) + .collect(); + glyph_names.sort(); + + glyph_names + .into_iter() + .filter_map(|glyph_name| { + self + .glyph_for_read(&glyph_name) + .map(|glyph| self.variation_report_for_glyph(&glyph_name, &glyph)) + }) + .collect() } #[napi] @@ -356,6 +444,10 @@ impl Bridge { #[napi] pub fn get_sources(&self) -> Vec { + if self.font.sources().is_empty() { + return vec![self.static_default_source().into()]; + } + self .font .sources() @@ -365,16 +457,53 @@ impl Bridge { .collect() } + fn static_default_source_id(&self) -> SourceId { + SourceId::from_raw(STATIC_DEFAULT_SOURCE_ID) + } + + fn static_default_source(&self) -> Source { + Source { + id: self.static_default_source_id(), + name: "Default".to_string(), + location: Location { + values: HashMap::new(), + }, + layer_id: self.font.default_layer_id(), + filename: None, + } + } + + fn source_layer_id(&self, source_id: SourceId) -> BridgeResult { + if let Some(source) = self + .font + .sources() + .iter() + .find(|source| source.id() == source_id) + { + return Ok(source.layer_id()); + } + + if self.font.sources().is_empty() && source_id == self.static_default_source_id() { + return Ok(self.font.default_layer_id()); + } + + Err(BridgeError::InvalidInput { + kind: "source ID", + value: source_id.to_string(), + }) + } + fn start_edit_session_for_name( &mut self, glyph_name: &str, + source_id: SourceId, unicode_hint: Option, - ) -> Result<()> { + ) -> errors::Result<()> { if self.active_edit.is_some() { - return Err(to_napi_error(BridgeError::ActiveEditAlreadyExists)); + return Err(BridgeError::ActiveEditAlreadyExists); } - let default_layer_id = self.font.default_layer_id(); + let layer_id = self.source_layer_id(source_id)?; let glyph = self .font .glyph(glyph_name) @@ -383,7 +512,8 @@ impl Bridge { self.active_edit = Some(ActiveEdit::from_glyph( glyph, - default_layer_id, + source_id, + layer_id, unicode_hint, )); @@ -391,8 +521,13 @@ impl Bridge { } #[napi] - pub fn start_edit_session(&mut self, glyph_ref: GlyphHandle) -> Result<()> { - self.start_edit_session_for_name(&glyph_ref.name, glyph_ref.unicode) + pub fn start_edit_session( + &mut self, + glyph_handle: GlyphHandle, + #[napi(ts_arg_type = "SourceId")] source_id: String, + ) -> errors::Result<()> { + let source_id = parse::(&source_id)?; + self.start_edit_session_for_name(&glyph_handle.name, source_id, glyph_handle.unicode) } fn active_edit(&self) -> BridgeResult<&ActiveEdit> { @@ -435,6 +570,105 @@ impl Bridge { .or_else(|| self.font.glyph(glyph_name).cloned()) } + fn variation_build_for_glyph(&self, glyph: &Glyph) -> Option<(usize, GlyphVariationBuild)> { + build_masters(&self.font, glyph).map(|masters| { + let master_count = masters.len(); + let build = build_glyph_variation_data(&masters, self.font.axes()); + (master_count, build) + }) + } + + fn variation_diagnostics_for_build( + glyph_name: &str, + build: &GlyphVariationBuild, + ) -> Vec { + let mut diagnostics = Vec::new(); + + if build.missing_default_source { + diagnostics.push(NapiGlyphVariationDiagnostic { + glyph_name: glyph_name.to_string(), + code: "missing-default-source".to_string(), + severity: "error".to_string(), + source: None, + message: "glyph has variation masters, but none belongs to the default source".to_string(), + }); + } + + diagnostics.extend( + build + .source_errors + .iter() + .map(|error| NapiGlyphVariationDiagnostic { + glyph_name: glyph_name.to_string(), + code: "incompatible-source".to_string(), + severity: "warning".to_string(), + source: Some(NapiGlyphVariationDiagnosticSource { + id: error.source_id.clone(), + index: error.source_index.min(u32::MAX as usize) as u32, + name: error.source_name.clone(), + }), + message: error.message.clone(), + }), + ); + + if let Some(message) = &build.model_error { + diagnostics.push(NapiGlyphVariationDiagnostic { + glyph_name: glyph_name.to_string(), + code: "variation-model-failed".to_string(), + severity: "error".to_string(), + source: None, + message: message.clone(), + }); + } + + diagnostics + } + + fn variation_report_for_glyph( + &self, + glyph_name: &str, + glyph: &Glyph, + ) -> NapiGlyphVariationReport { + let Some((master_count, build)) = self.variation_build_for_glyph(glyph) else { + return NapiGlyphVariationReport { + glyph_name: glyph_name.to_string(), + status: "static".to_string(), + variation_data_available: false, + master_count: 0, + compatible_master_count: 0, + skipped_master_count: 0, + diagnostics: Vec::new(), + }; + }; + + let diagnostics = Self::variation_diagnostics_for_build(glyph_name, &build); + let skipped_master_count = build.source_errors.len(); + let compatible_master_count = if build.missing_default_source { + 0 + } else { + master_count.saturating_sub(skipped_master_count) + }; + let variation_data_available = build.variation_data.is_some(); + let status = match ( + variation_data_available, + skipped_master_count > 0 || !diagnostics.is_empty(), + ) { + (true, false) => "variable", + (true, true) => "partial", + (false, _) => "unavailable", + }; + + NapiGlyphVariationReport { + glyph_name: glyph_name.to_string(), + status: status.to_string(), + variation_data_available, + master_count: master_count.min(u32::MAX as usize) as u32, + compatible_master_count: compatible_master_count.min(u32::MAX as usize) as u32, + skipped_master_count: skipped_master_count.min(u32::MAX as usize) as u32, + diagnostics, + } + } + fn mark_active_edit_changed(&mut self) { self.bump_live_version(); if let Some(active_edit) = self.active_edit.as_mut() { @@ -458,11 +692,6 @@ impl Bridge { DocumentVersion(self.persisted_version.load(Ordering::Acquire)) } - #[napi] - pub fn get_live_version(&self) -> u32 { - self.live_version().as_u32() - } - #[napi] pub fn get_persisted_version(&self) -> u32 { self.persisted_version().as_u32() @@ -504,6 +733,14 @@ impl Bridge { .map(|session| session.glyph_name().to_string()) } + #[napi(ts_return_type = "SourceId | null")] + pub fn get_editing_source_id(&self) -> Option { + self + .active_edit() + .ok() + .map(|edit| edit.source_id().to_string()) + } + #[napi] pub fn set_x_advance(&mut self, width: f64) -> errors::Result { let session = self.active_session_mut()?; @@ -851,6 +1088,10 @@ mod tests { font } + fn default_source_id(bridge: &Bridge) -> String { + bridge.get_sources()[0].id.clone() + } + fn print_perf_mark(operation: &str, mark: PerfFontMark, elapsed: Duration) { eprintln!( "perf_mark {operation} [{}]: {} glyphs / {} points in {:?}", @@ -883,11 +1124,15 @@ mod tests { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); assert!(bridge.has_edit_session()); assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); + assert_eq!( + bridge.get_editing_source_id().as_deref(), + Some(default_source_id(&bridge).as_str()) + ); assert_eq!(bridge.get_editing_unicode(), Some(65)); } @@ -896,7 +1141,7 @@ mod tests { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); bridge.end_edit_session().unwrap(); @@ -912,11 +1157,14 @@ mod tests { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); - let result = bridge.start_edit_session(glyph_handle("B", Some(66))); + let result = bridge.start_edit_session(glyph_handle("B", Some(66)), default_source_id(&bridge)); - assert_eq!(result.unwrap_err().reason, "edit session already active"); + assert_eq!( + result.unwrap_err().to_string(), + "edit session already active" + ); assert_eq!(bridge.get_editing_glyph_name().as_deref(), Some("A")); } @@ -924,7 +1172,7 @@ mod tests { fn add_contour_returns_structure_change() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); let change = bridge.add_contour().unwrap(); @@ -942,7 +1190,7 @@ mod tests { fn save_snapshot_includes_active_edit_without_committing_session() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); let point_id = bridge @@ -976,7 +1224,7 @@ mod tests { fn persisted_older_snapshot_keeps_document_dirty_after_new_edit() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); let snapshot = bridge.save_snapshot(); @@ -987,7 +1235,7 @@ mod tests { record_persisted_version(&bridge.persisted_version, snapshot.version()); assert_eq!(snapshot.version().as_u32(), 1); - assert_eq!(bridge.get_live_version(), 2); + assert_eq!(bridge.live_version().as_u32(), 2); assert_eq!(bridge.get_persisted_version(), 1); assert!(bridge.is_dirty()); } @@ -996,7 +1244,7 @@ mod tests { fn load_resets_persisted_version_handle_for_old_async_saves() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); bridge.add_contour().unwrap(); let old_persisted_version = bridge.persisted_version.clone(); @@ -1015,13 +1263,13 @@ mod tests { fn ending_dirty_edit_session_does_not_increment_version_again() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); bridge.add_contour().unwrap(); bridge.end_edit_session().unwrap(); - assert_eq!(bridge.get_live_version(), 1); + assert_eq!(bridge.live_version().as_u32(), 1); assert!(bridge.is_dirty()); assert_eq!(bridge.get_glyphs()[0].name, "A"); } @@ -1030,7 +1278,7 @@ mod tests { fn add_point_returns_structure_and_changed_point() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); @@ -1050,7 +1298,7 @@ mod tests { fn get_glyph_state_reads_active_edit_overlay() { let mut bridge = Bridge::new(); bridge - .start_edit_session(glyph_handle("A", Some(65))) + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) .unwrap(); let contour_id = bridge.add_contour().unwrap().changed.contour_ids[0].clone(); bridge @@ -1058,7 +1306,8 @@ mod tests { .unwrap(); let state = bridge - .get_glyph_state(glyph_handle("A", Some(65))) + .get_glyph_state(glyph_handle("A", Some(65)), default_source_id(&bridge)) + .unwrap() .expect("active edit glyph should be readable"); assert!(bridge.get_glyphs().is_empty()); @@ -1072,7 +1321,8 @@ mod tests { let bridge = Bridge::new(); assert!(bridge - .get_glyph_state(glyph_handle("missing", None)) + .get_glyph_state(glyph_handle("missing", None), default_source_id(&bridge)) + .unwrap() .is_none()); } @@ -1105,6 +1355,7 @@ mod tests { let active_glyph = point_heavy_glyph("active", 0xE000, default_layer_id, active_mark); bridge.active_edit = Some(ActiveEdit::from_glyph( active_glyph, + bridge.static_default_source_id(), default_layer_id, Some(0xE000), )); @@ -1122,7 +1373,7 @@ mod tests { .layer(snapshot.default_layer_id()) .expect("active overlay should include the default layer"); - assert_eq!(snapshot.version().as_u32(), bridge.get_live_version()); + assert_eq!(snapshot.version().as_u32(), bridge.live_version().as_u32()); assert_eq!(snapshot.glyphs().len(), committed_mark.glyphs + 1); assert_eq!(active_glyph.unicodes(), &[0xE000]); assert_eq!( diff --git a/crates/shift-bridge/src/input.rs b/crates/shift-bridge/src/input.rs index 82c2d790..f25ebc4f 100644 --- a/crates/shift-bridge/src/input.rs +++ b/crates/shift-bridge/src/input.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use shift_ir::{AnchorId, ComponentId, ContourId, GuidelineId, LayerId, PointId}; +use shift_ir::{AnchorId, ComponentId, ContourId, GuidelineId, LayerId, PointId, SourceId}; use crate::errors::{BridgeError, BridgeResult}; @@ -42,3 +42,7 @@ impl BridgeParse for GuidelineId { impl BridgeParse for LayerId { const KIND: &'static str = "layer ID"; } + +impl BridgeParse for SourceId { + const KIND: &'static str = "source ID"; +} diff --git a/crates/shift-edit/src/interpolation.rs b/crates/shift-edit/src/interpolation.rs index 425f7999..806adb78 100644 --- a/crates/shift-edit/src/interpolation.rs +++ b/crates/shift-edit/src/interpolation.rs @@ -14,10 +14,48 @@ use crate::{Font, Glyph}; #[derive(Debug, Clone)] pub struct SourceError { pub source_index: usize, + pub source_id: String, pub source_name: String, pub message: String, } +#[derive(Debug, Clone)] +pub struct GlyphVariationBuild { + pub variation_data: Option, + pub source_errors: Vec, + pub missing_default_source: bool, + pub model_error: Option, +} + +impl GlyphVariationBuild { + fn data(variation_data: GlyphVariationData, source_errors: Vec) -> Self { + Self { + variation_data: Some(variation_data), + source_errors, + missing_default_source: false, + model_error: None, + } + } + + fn missing_default() -> Self { + Self { + variation_data: None, + source_errors: Vec::new(), + missing_default_source: true, + model_error: None, + } + } + + fn model_failed(source_errors: Vec, message: String) -> Self { + Self { + variation_data: None, + source_errors, + missing_default_source: false, + model_error: Some(message), + } + } +} + fn check_compatibility(a: &GlyphStructure, b: &GlyphStructure) -> Result<(), String> { // Contour topology must match so point values line up by index. if a.contours.len() != b.contours.len() { @@ -49,12 +87,6 @@ fn check_compatibility(a: &GlyphStructure, b: &GlyphStructure) -> Result<(), Str if pa.point_type != pb.point_type { return Err(format!("contour {i} point {j} type mismatch")); } - if pa.smooth != pb.smooth { - return Err(format!( - "contour {i} point {j} smooth mismatch: {} vs {}", - pa.smooth, pb.smooth - )); - } } } @@ -162,16 +194,15 @@ pub fn build_masters(font: &Font, glyph: &Glyph) -> Option> { } } -pub fn get_glyph_variation_data( - masters: &[GlyphMaster], - axes: &[Axis], -) -> Option { +pub fn build_glyph_variation_data(masters: &[GlyphMaster], axes: &[Axis]) -> GlyphVariationBuild { let ordered_axes: Vec = axes .iter() .filter_map(|a| Tag::from_str(a.tag()).ok()) .collect(); - let default_master = masters.iter().find(|master| master.is_default_source)?; + let Some(default_master) = masters.iter().find(|master| master.is_default_source) else { + return GlyphVariationBuild::missing_default(); + }; let mut errors = Vec::new(); let mut points: HashMap> = HashMap::new(); @@ -184,6 +215,7 @@ pub fn get_glyph_variation_data( Err(message) => { errors.push(SourceError { source_index, + source_id: master.source_id.clone(), source_name: master.source_name.clone(), message, }); @@ -193,7 +225,10 @@ pub fn get_glyph_variation_data( let locations_set: HashSet = points.keys().cloned().collect(); let model = VariationModel::new(locations_set, ordered_axes); - let model_deltas = model.deltas::(&points).ok()?; + let model_deltas = match model.deltas::(&points) { + Ok(model_deltas) => model_deltas, + Err(err) => return GlyphVariationBuild::model_failed(errors, err.to_string()), + }; let regions: Vec> = model_deltas .iter() @@ -212,5 +247,88 @@ pub fn get_glyph_variation_data( let deltas: Vec> = model_deltas.into_iter().map(|(_, d)| d).collect(); - Some(GlyphVariationData { regions, deltas }) + GlyphVariationBuild::data(GlyphVariationData { regions, deltas }, errors) +} + +pub fn get_glyph_variation_data( + masters: &[GlyphMaster], + axes: &[Axis], +) -> Option { + build_glyph_variation_data(masters, axes).variation_data +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use shift_wire::{ContourData, GlyphMaster, GlyphStructure, Location, PointData, PointType}; + + use super::build_glyph_variation_data; + use shift_ir::Axis; + + fn structure_with_smooth(smooth: bool) -> GlyphStructure { + GlyphStructure { + contours: vec![ContourData { + id: "contour-1".to_string(), + closed: false, + points: vec![ + PointData { + id: "point-1".to_string(), + point_type: PointType::OnCurve, + smooth, + }, + PointData { + id: "point-2".to_string(), + point_type: PointType::OnCurve, + smooth: false, + }, + ], + }], + anchors: Vec::new(), + components: Vec::new(), + } + } + + fn master( + source_name: &str, + is_default_source: bool, + location_value: f64, + smooth: bool, + x_offset: f64, + ) -> GlyphMaster { + GlyphMaster { + source_id: source_name.to_string(), + source_name: source_name.to_string(), + is_default_source, + location: Location { + values: HashMap::from([("wght".to_string(), location_value)]), + }, + structure: structure_with_smooth(smooth), + values: vec![500.0, x_offset, 0.0, 100.0 + x_offset, 0.0], + } + } + + #[test] + fn smooth_point_mismatch_does_not_make_masters_incompatible() { + let axes = vec![Axis::new( + "wght".to_string(), + "Weight".to_string(), + 0.0, + 0.0, + 100.0, + )]; + let masters = vec![ + master("Regular", true, 0.0, false, 0.0), + master("Bold", false, 100.0, true, 20.0), + ]; + + let build = build_glyph_variation_data(&masters, &axes); + + assert!( + build.source_errors.is_empty(), + "smooth-only mismatch should not skip sources: {:?}", + build.source_errors + ); + assert!(build.variation_data.is_some()); + } } diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 540bc2ef..f89e8cef 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -13,50 +13,50 @@ Central routing table for Shift's distributed documentation. Before creating new ### Rust crates -| Path pattern | Canonical doc | Purpose | -|---|---|---| -| `crates/shift-core/**` | [`crates/shift-core/docs/DOCS.md`](../../crates/shift-core/docs/DOCS.md) | Core data structures and editing logic (Font, Glyph, Contour, Point, EditSession) | -| `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | -| `crates/shift-ir/**` | [`crates/shift-ir/docs/DOCS.md`](../../crates/shift-ir/docs/DOCS.md) | Format-agnostic intermediate representation for the font model | -| `crates/shift-node/**` | [`crates/shift-node/docs/DOCS.md`](../../crates/shift-node/docs/DOCS.md) | NAPI bindings exposing Rust to Node.js/Electron | +| Path pattern | Canonical doc | Purpose | +| -------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `crates/shift-edit/**` | [`crates/shift-edit/docs/DOCS.md`](../../crates/shift-edit/docs/DOCS.md) | Editing logic and composite helpers | +| `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | +| `crates/shift-ir/**` | [`crates/shift-ir/docs/DOCS.md`](../../crates/shift-ir/docs/DOCS.md) | Format-agnostic intermediate representation for the font model | +| `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | ### Desktop app — Electron shell -| Path pattern | Canonical doc | Purpose | -|---|---|---| -| `apps/desktop/src/main/**` | [`apps/desktop/src/main/docs/DOCS.md`](../../apps/desktop/src/main/docs/DOCS.md) | Electron main process: lifecycle, windows, menus, document state | -| `apps/desktop/src/preload/**` | [`apps/desktop/src/preload/docs/DOCS.md`](../../apps/desktop/src/preload/docs/DOCS.md) | Preload script bridging native Rust FontEngine to renderer | -| `apps/desktop/src/shared/bridge/**` | [`apps/desktop/src/shared/bridge/docs/DOCS.md`](../../apps/desktop/src/shared/bridge/docs/DOCS.md) | Type-safe preload bridge system (FontEngineAPI contract) | +| Path pattern | Canonical doc | Purpose | +| ----------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `apps/desktop/src/main/**` | [`apps/desktop/src/main/docs/DOCS.md`](../../apps/desktop/src/main/docs/DOCS.md) | Electron main process: lifecycle, windows, menus, document state | +| `apps/desktop/src/preload/**` | [`apps/desktop/src/preload/docs/DOCS.md`](../../apps/desktop/src/preload/docs/DOCS.md) | Preload script bridging native Rust FontEngine to renderer | +| `apps/desktop/src/shared/bridge/**` | [`apps/desktop/src/shared/bridge/docs/DOCS.md`](../../apps/desktop/src/shared/bridge/docs/DOCS.md) | Type-safe preload bridge system (FontEngineAPI contract) | ### Desktop app — Renderer -| Path pattern | Canonical doc | Purpose | -|---|---|---| -| `apps/desktop/src/renderer/src/bridge/**` | [`apps/desktop/src/renderer/src/bridge/docs/DOCS.md`](../../apps/desktop/src/renderer/src/bridge/docs/DOCS.md) | NativeBridge: reactive wrapper over NAPI, owns Glyph lifecycle | -| `apps/desktop/src/renderer/src/lib/editor/**` | [`apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md) | Canvas-based glyph editor, viewport transforms, selection | -| `apps/desktop/src/renderer/src/lib/tools/**` | [`apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md) | State machine-based tool system (BaseTool, behaviors, actions) | -| `apps/desktop/src/renderer/src/lib/graphics/**` | [`apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md) | Rendering abstraction with Canvas 2D backend and path caching | -| `apps/desktop/src/renderer/src/lib/transform/**` | [`apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md) | Geometry transforms: rotate, scale, reflect selected points | -| `apps/desktop/src/renderer/src/lib/commands/**` | [`apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md) | Command pattern with undo/redo for all editing operations | -| `apps/desktop/src/renderer/src/lib/reactive/**` | [`apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md) | Fine-grained reactivity: dependency tracking and efficient updates | +| Path pattern | Canonical doc | Purpose | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `apps/desktop/src/renderer/src/bridge/**` | [`apps/desktop/src/renderer/src/bridge/docs/DOCS.md`](../../apps/desktop/src/renderer/src/bridge/docs/DOCS.md) | NativeBridge: reactive wrapper over NAPI, owns Glyph lifecycle | +| `apps/desktop/src/renderer/src/lib/editor/**` | [`apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md) | Canvas-based glyph editor, viewport transforms, selection | +| `apps/desktop/src/renderer/src/lib/tools/**` | [`apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md) | State machine-based tool system (BaseTool, behaviors, actions) | +| `apps/desktop/src/renderer/src/lib/graphics/**` | [`apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md) | Rendering abstraction with Canvas 2D backend and path caching | +| `apps/desktop/src/renderer/src/lib/transform/**` | [`apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md) | Geometry transforms: rotate, scale, reflect selected points | +| `apps/desktop/src/renderer/src/lib/commands/**` | [`apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md) | Command pattern with undo/redo for all editing operations | +| `apps/desktop/src/renderer/src/lib/reactive/**` | [`apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md) | Fine-grained reactivity: dependency tracking and efficient updates | ### Packages -| Path pattern | Canonical doc | Purpose | -|---|---|---| -| `packages/types/**` | [`packages/types/docs/DOCS.md`](../../packages/types/docs/DOCS.md) | Generated + domain types (API boundary) | -| `packages/geo/**` | [`packages/geo/docs/DOCS.md`](../../packages/geo/docs/DOCS.md) | Geometry utilities (Vec2, Curve, Polygon, Mat) | -| `packages/font/**` | [`packages/font/docs/DOCS.md`](../../packages/font/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | -| `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | -| `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | -| `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | +| Path pattern | Canonical doc | Purpose | +| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `packages/types/**` | [`packages/types/docs/DOCS.md`](../../packages/types/docs/DOCS.md) | Branded IDs, bridge DTO facade, and legacy editor migration types | +| `packages/geo/**` | [`packages/geo/docs/DOCS.md`](../../packages/geo/docs/DOCS.md) | Geometry utilities (Vec2, Curve, Polygon, Mat) | +| `packages/glyph-state/**` | [`packages/glyph-state/docs/DOCS.md`](../../packages/glyph-state/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | +| `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | +| `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | +| `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | ## API Boundaries These modules have stricter change rules. Changes affect multiple layers and require `pnpm typecheck` to validate. - **`FontEngineAPI`** (`apps/desktop/src/shared/bridge/FontEngineAPI.ts`) — the single type definition bridging preload and renderer. Changes here affect both sides of the Electron boundary. -- **`@shift/types`** (`packages/types/`) — generated types from Rust are the source of truth. Domain types must derive from generated types, never re-declare structure. +- **`@shift/types/bridge`** (`packages/types/src/bridge/`) — generated bridge DTO facade sourced from `crates/shift-bridge/index.d.ts`. - **`NativeBridge`** (`apps/desktop/src/renderer/src/bridge/`) — the single class wrapping NAPI bindings. All Rust access goes through NativeBridge. ## Validation diff --git a/packages/font/docs/DOCS.md b/packages/font/docs/DOCS.md deleted file mode 100644 index 441bda25..00000000 --- a/packages/font/docs/DOCS.md +++ /dev/null @@ -1,87 +0,0 @@ -# Font - -Pure, stateless query functions for glyph and contour domain structures. - -## Architecture Invariants - -- **Architecture Invariant:** Every function in `Glyphs` and `Contours` is pure -- it reads the glyph/contour and returns a result without mutation. These namespaces must never hold state or modify their inputs. -- **Architecture Invariant:** The domain types (`Glyph`, `Contour`, `Point`, `GlyphSnapshot`) live in `@shift/types`, not here. This package only provides operations over those types; it never defines the shapes themselves. -- **Architecture Invariant:** `Contours.at` wraps indices for closed contours by default (`wrap = contour.closed`). Neighbor lookups (`neighbors`, `withNeighbors`) depend on this wrapping behavior -- open contours yield `null` at boundaries, closed contours wrap around. -- **Architecture Invariant:** `parseContourSegments` determines segment type by scanning the `pointType` sequence (onCurve/offCurve). Two consecutive onCurve points produce a line; onCurve-offCurve-onCurve produces a quad; onCurve-offCurve-offCurve-onCurve produces a cubic. No other patterns are recognized. -- **Architecture Invariant:** `areGlyphSnapshotsEqual` compares snapshots field-by-field (not via generic deep-equal) to keep history-commit checks fast and predictable. Any new field added to `GlyphSnapshot` must be added to this function or equality checks will silently ignore it. - -## Codemap - -``` -packages/font/src/ - index.ts -- public API barrel; exports Contours, Glyphs, areGlyphSnapshotsEqual, - geometry functions and types - Glyph.ts -- Glyphs namespace: point lookup, iteration, spatial queries over Glyph - Contour.ts -- Contours namespace: point access, neighbor traversal, open/closed queries - GlyphEquality.ts -- areGlyphSnapshotsEqual: value-based snapshot comparison for undo dedup - GlyphGeometry.ts -- segment parsing, curve conversion, bounding-box derivation -``` - -## Key Types - -- **`Glyphs`** -- namespace object with `findPoint`, `findContour`, `points` (generator), `findPoints`, `getAllPoints`, `getPointAt`. All take a `Glyph` as first argument. -- **`Contours`** -- namespace object with `firstPoint`, `lastPoint`, `firstOnCurvePoint`, `lastOnCurvePoint`, `getOnCurvePoints`, `getOffCurvePoints`, `findPointById`, `findPointIndex`, `isOpen`, `isEmpty`, `hasInteriorPoints`, `canClose`, `pointCount`, `at`, `neighbors`, `withNeighbors`. All take a `Contour` as first argument. -- **`PointInContour`** -- returned by `Glyphs.points` and `Glyphs.findPoint`: `{ point, contour, index }`. -- **`PointWithNeighbors`** -- returned by `Contours.withNeighbors`: `{ prev, current, next, index, isFirst, isLast }`. -- **`SegmentGeometry`** -- discriminated union (`LineSegmentGeometry | QuadSegmentGeometry | CubicSegmentGeometry`) produced by `parseContourSegments`. -- **`SegmentContourLike`** -- minimal contour shape (`{ points, closed }`) accepted by `parseContourSegments`, allowing it to work with both `Contour` and `RenderContour`. -- **`areGlyphSnapshotsEqual`** -- standalone function comparing two `GlyphSnapshot` values field-by-field. - -## How it works - -The package is organized as two functional namespaces (`Glyphs`, `Contours`) plus standalone geometry/equality functions. - -**Point lookup and iteration.** `Glyphs.findPoint` does a linear scan across all contours to locate a point by `PointId`, returning the point, its parent contour, and index. `Glyphs.points` is a generator that lazily yields every point with contour context. `Glyphs.getPointAt` finds the first point within a given radius of a position (used for hit-testing). - -**Contour traversal.** `Contours.at` handles index wrapping for closed contours using modular arithmetic. `Contours.withNeighbors` builds on this to yield each point with its previous/next neighbors -- for closed contours the last point's `next` wraps to the first, and vice versa. `Contours.canClose` checks whether a drawing position is close enough to the first point to close the contour (used by the pen tool). - -**Segment parsing.** `parseContourSegments` walks a contour's point array and emits typed `SegmentGeometry` values (line/quad/cubic) based on onCurve/offCurve patterns. `segmentToCurve` converts each segment into a `CurveType` from `@shift/geo` for mathematical operations. `deriveGlyphTightBounds` composes these to compute axis-aligned bounding boxes over all contours (including composite contours). - -**Snapshot equality.** `areGlyphSnapshotsEqual` performs a structured comparison of two `GlyphSnapshot` objects, checking scalar fields, then array-comparing contours, anchors, and composite contours element-by-element. This avoids generic deep-equal overhead and is used by the undo system to skip no-op history entries. - -## Workflow recipes - -### Add a new query to Glyphs or Contours - -1. Add the function to the `Glyphs` or `Contours` object in `Glyph.ts` / `Contour.ts`. -2. Keep it pure: take `Glyph` or `Contour` as first arg, return without mutation. -3. Export from `index.ts` if it introduces a new type. -4. Add a test case in the corresponding `.test.ts` file. -5. Run `pnpm test` in the package. - -### Add a new field to GlyphSnapshot - -1. Add the field in `@shift/types` (the snapshot is generated from Rust). -2. Update `areGlyphSnapshotsEqual` in `GlyphEquality.ts` to compare the new field. -3. Add a test case in `GlyphEquality.test.ts` verifying that differing values return `false`. - -### Use segment geometry for rendering or hit-testing - -1. Call `parseContourSegments(contour)` to get `SegmentGeometry[]`. -2. For each segment, call `segmentToCurve(segment)` to get a `CurveType`. -3. Use `Curve.bounds`, `Curve.evaluate`, etc. from `@shift/geo` on the result. - -## Gotchas - -- **`getPointAt` returns the first match**: It scans contours in order and returns the first point within radius. If two points overlap, the one earlier in contour iteration order wins. -- **`parseContourSegments` silently stops on unexpected patterns**: If the point sequence doesn't match line/quad/cubic patterns (e.g., an offCurve at position 0), the parser breaks out of its loop. No error is thrown; you just get fewer segments. -- **`SegmentContourLike` vs `Contour`**: Geometry functions accept `SegmentContourLike` (points with optional `id`) so they work with `RenderContour` (composite contours that lack `id`/`ContourId`). Don't accidentally narrow the parameter type to `Contour`. -- **`areGlyphSnapshotsEqual` must be kept in sync**: If a new field is added to `GlyphSnapshot` but not to `areGlyphSnapshotsEqual`, changes to that field won't create undo history entries. - -## Verification - -- `pnpm --filter @shift/font test` -- runs all unit tests (Glyph, Contour, GlyphEquality, GlyphGeometry). -- `pnpm --filter @shift/font typecheck` -- confirms type correctness against `@shift/types` and `@shift/geo`. - -## Related - -- **`Glyph`**, **`Contour`**, **`Point`**, **`GlyphSnapshot`** (`@shift/types`) -- the domain types this package operates on. -- **`Vec2`**, **`Bounds`**, **`Curve`** (`@shift/geo`) -- geometric primitives used by `GlyphGeometry` and `Contours.canClose`. -- **`Glyph` reactive model** (renderer `lib/model/Glyph.ts`) -- imports `Glyphs`, `Contours`, `parseContourSegments`, and `segmentToCurve` to build the reactive glyph wrapper. -- **`NativeBridge`** (renderer bridge) -- uses `Glyphs` for point lookup after native engine calls. -- **`Contours`** usage in pen tool -- `Contours.canClose`, `Contours.lastOnCurvePoint`, `Contours.firstPoint` drive pen drawing behavior. diff --git a/packages/font/src/Contour.test.ts b/packages/font/src/Contour.test.ts deleted file mode 100644 index 790fb347..00000000 --- a/packages/font/src/Contour.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Contours } from "./Contour"; -import type { Point, Contour, PointId, ContourId } from "@shift/types"; - -function makePoint( - id: string, - x: number, - y: number, - pointType: "onCurve" | "offCurve" = "onCurve", -): Point { - return { id: id as PointId, x, y, pointType, smooth: false }; -} - -function makeContour(id: string, points: Point[], closed = false): Contour { - return { id: id as ContourId, points, closed }; -} - -describe("Contours.withNeighbors", () => { - it("yields nothing for empty contour", () => { - const contour = makeContour("c1", []); - const result = [...Contours.withNeighbors(contour)]; - expect(result).toHaveLength(0); - }); - - it("yields single point with null neighbors for open contour", () => { - const p1 = makePoint("p1", 0, 0); - const contour = makeContour("c1", [p1], false); - const result = [...Contours.withNeighbors(contour)]; - - expect(result).toHaveLength(1); - expect(result[0].current).toBe(p1); - expect(result[0].prev).toBeNull(); - expect(result[0].next).toBeNull(); - expect(result[0].index).toBe(0); - expect(result[0].isFirst).toBe(true); - expect(result[0].isLast).toBe(true); - }); - - it("yields correct neighbors for open contour", () => { - const p1 = makePoint("p1", 0, 0); - const p2 = makePoint("p2", 50, 50); - const p3 = makePoint("p3", 100, 0); - const contour = makeContour("c1", [p1, p2, p3], false); - const result = [...Contours.withNeighbors(contour)]; - - expect(result).toHaveLength(3); - - expect(result[0].current).toBe(p1); - expect(result[0].prev).toBeNull(); - expect(result[0].next).toBe(p2); - expect(result[0].isFirst).toBe(true); - expect(result[0].isLast).toBe(false); - - expect(result[1].current).toBe(p2); - expect(result[1].prev).toBe(p1); - expect(result[1].next).toBe(p3); - expect(result[1].isFirst).toBe(false); - expect(result[1].isLast).toBe(false); - - expect(result[2].current).toBe(p3); - expect(result[2].prev).toBe(p2); - expect(result[2].next).toBeNull(); - expect(result[2].isFirst).toBe(false); - expect(result[2].isLast).toBe(true); - }); - - it("wraps neighbors for closed contour", () => { - const p1 = makePoint("p1", 0, 0); - const p2 = makePoint("p2", 100, 0); - const p3 = makePoint("p3", 50, 100); - const contour = makeContour("c1", [p1, p2, p3], true); - const result = [...Contours.withNeighbors(contour)]; - - expect(result).toHaveLength(3); - - expect(result[0].current).toBe(p1); - expect(result[0].prev).toBe(p3); - expect(result[0].next).toBe(p2); - - expect(result[2].current).toBe(p3); - expect(result[2].prev).toBe(p2); - expect(result[2].next).toBe(p1); - }); - - it("tracks correct indices", () => { - const points = [makePoint("p1", 0, 0), makePoint("p2", 50, 50), makePoint("p3", 100, 0)]; - const contour = makeContour("c1", points, false); - const indices = [...Contours.withNeighbors(contour)].map((r) => r.index); - expect(indices).toEqual([0, 1, 2]); - }); - - it("single point in closed contour wraps to itself", () => { - const p1 = makePoint("p1", 0, 0); - const contour = makeContour("c1", [p1], true); - const result = [...Contours.withNeighbors(contour)]; - - expect(result).toHaveLength(1); - expect(result[0].prev).toBe(p1); - expect(result[0].next).toBe(p1); - }); -}); diff --git a/packages/font/src/Contour.ts b/packages/font/src/Contour.ts deleted file mode 100644 index 57fccceb..00000000 --- a/packages/font/src/Contour.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Contours - Utility functions for contour queries - * - * A functional namespace for querying contour data structures. - * Works with the Contour type from @shift/types. - * - * Design principles: - * - Pure functions (no mutation) - * - Works with readonly Contour objects - * - No class instantiation overhead - * - Tree-shakeable (import only what you need) - * - * @example - * ```ts - * import { Contours } from '@shift/font'; - * - * const first = Contours.firstPoint(contour); - * const last = Contours.lastOnCurvePoint(contour); - * const isOpen = Contours.isOpen(contour); - * ``` - */ - -import { Vec2 } from "@shift/geo"; -import type { Point, Contour, PointId, Point2D } from "@shift/types"; - -type PointCollection = { - readonly points: readonly TPoint[]; -}; - -type PointIdCarrier = { - readonly id: PointId; -}; - -export interface PointWithNeighbors { - prev: Point | null; - current: Point; - next: Point | null; - index: number; - isFirst: boolean; - isLast: boolean; -} - -export const Contours = { - /** - * Get the first point of a contour - */ - firstPoint(contour: Contour): Point | null { - return contour.points[0] ?? null; - }, - - /** - * Get the last point of a contour - */ - lastPoint(contour: Contour): Point | null { - const { points } = contour; - return points[points.length - 1] ?? null; - }, - - /** - * Get the first on-curve point of a contour - */ - firstOnCurvePoint(contour: Contour): Point | null { - for (const point of contour.points) { - if (point.pointType === "onCurve") { - return point; - } - } - return null; - }, - - /** - * Get the last on-curve point of a contour - */ - lastOnCurvePoint(contour: Contour): Point | null { - const { points } = contour; - for (let i = points.length - 1; i >= 0; i--) { - const point = points[i]; - if (point?.pointType === "onCurve") { - return point; - } - } - return null; - }, - - /** - * Get all on-curve points in a contour - */ - getOnCurvePoints(contour: Contour): readonly Point[] { - return contour.points.filter((p) => p.pointType === "onCurve"); - }, - - /** - * Get all off-curve (control) points in a contour - */ - getOffCurvePoints(contour: Contour): readonly Point[] { - return contour.points.filter((p) => p.pointType === "offCurve"); - }, - - /** - * Find a point by its ID within a contour - */ - findPointById( - contour: PointCollection, - id: PointId, - ): TPoint | null { - return contour.points.find((p) => p.id === id) ?? null; - }, - - /** - * Find the index of a point by its ID within a contour - * Returns -1 if not found - */ - findPointIndex( - contour: PointCollection, - id: PointId, - ): number { - return contour.points.findIndex((p) => p.id === id); - }, - - /** - * Check if a contour is open (not closed) - */ - isOpen(contour: Contour): boolean { - return !contour.closed; - }, - - /** - * Check if a contour has no points - */ - isEmpty(contour: Contour): boolean { - return contour.points.length === 0; - }, - - /** - * Check if a contour has interior points (points that are not endpoints). - * For open contours, this means at least 3 points. - */ - hasInteriorPoints(contour: Contour): boolean { - return contour.points.length >= 3; - }, - - canClose(contour: Contour, position: Point2D, hitRadius: number): boolean { - if (!this.hasInteriorPoints(contour) || contour.closed) return false; - - const firstPoint = this.firstPoint(contour); - if (!firstPoint) return false; - - return Vec2.isWithin(position, firstPoint, hitRadius); - }, - - /** - * Get the number of points in a contour - */ - pointCount(contour: Contour): number { - return contour.points.length; - }, - - /** - * Get a point by index, optionally wrapping for closed contours. - */ - at(contour: Contour, index: number, wrap = contour.closed): Point | null { - const { points } = contour; - if (index >= 0 && index < points.length) return points[index] ?? null; - if (!wrap || points.length === 0) return null; - const wrapped = ((index % points.length) + points.length) % points.length; - return points[wrapped] ?? null; - }, - - /** - * Get neighbors around an index. - */ - neighbors(contour: Contour, index: number): { prev: Point | null; next: Point | null } { - return { - prev: Contours.at(contour, index - 1), - next: Contours.at(contour, index + 1), - }; - }, - - *withNeighbors(contour: Contour): Generator { - const { points } = contour; - const len = points.length; - for (let i = 0; i < len; i++) { - const current = points[i]; - if (!current) { - continue; - } - yield { - prev: Contours.at(contour, i - 1), - current, - next: Contours.at(contour, i + 1), - index: i, - isFirst: i === 0, - isLast: i === len - 1, - }; - } - }, -} as const; diff --git a/packages/font/src/Glyph.test.ts b/packages/font/src/Glyph.test.ts deleted file mode 100644 index 5676d274..00000000 --- a/packages/font/src/Glyph.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { Glyphs } from "./Glyph"; -import type { Glyph, Point, Contour, PointId, ContourId } from "@shift/types"; - -function makePoint(id: string, x: number, y: number): Point { - return { - id: id as PointId, - x, - y, - pointType: "onCurve", - smooth: false, - }; -} - -function makeContour(id: string, points: Point[], closed = false): Contour { - return { id: id as ContourId, points, closed }; -} - -function makeGlyph(contours: Contour[]): Glyph { - return { - unicode: 65, - name: "A", - xAdvance: 500, - contours, - activeContourId: null, - anchors: [], - }; -} - -describe("Glyphs", () => { - const p1 = makePoint("p1", 0, 0); - const p2 = makePoint("p2", 100, 0); - const p3 = makePoint("p3", 100, 100); - const p4 = makePoint("p4", 200, 200); - - const c1 = makeContour("c1", [p1, p2, p3]); - const c2 = makeContour("c2", [p4]); - const glyph = makeGlyph([c1, c2]); - - describe("findPoint", () => { - it("finds a point by ID", () => { - const result = Glyphs.findPoint(glyph, "p2" as PointId); - expect(result).not.toBeNull(); - expect(result!.point).toBe(p2); - expect(result!.contour).toBe(c1); - expect(result!.index).toBe(1); - }); - - it("returns null for unknown ID", () => { - expect(Glyphs.findPoint(glyph, "unknown" as PointId)).toBeNull(); - }); - }); - - describe("findContour", () => { - it("finds a contour by ID", () => { - expect(Glyphs.findContour(glyph, "c1" as ContourId)).toBe(c1); - }); - - it("returns undefined for unknown ID", () => { - expect(Glyphs.findContour(glyph, "unknown" as ContourId)).toBeUndefined(); - }); - }); - - describe("findPoints", () => { - it("finds multiple points by IDs", () => { - const result = Glyphs.findPoints(glyph, ["p1" as PointId, "p4" as PointId]); - expect(result).toHaveLength(2); - expect(result[0]).toBe(p1); - expect(result[1]).toBe(p4); - }); - - it("returns empty for no matches", () => { - expect(Glyphs.findPoints(glyph, ["x" as PointId])).toHaveLength(0); - }); - }); - - describe("getAllPoints", () => { - it("returns all points across contours", () => { - const result = Glyphs.getAllPoints(glyph); - expect(result).toHaveLength(4); - expect(result).toEqual([p1, p2, p3, p4]); - }); - - it("returns empty for empty glyph", () => { - expect(Glyphs.getAllPoints(makeGlyph([]))).toHaveLength(0); - }); - }); - - describe("points", () => { - it("yields all points with contour context", () => { - const result = [...Glyphs.points(glyph)]; - expect(result).toHaveLength(4); - - expect(result[0].point).toBe(p1); - expect(result[0].contour).toBe(c1); - expect(result[0].index).toBe(0); - - expect(result[1].point).toBe(p2); - expect(result[1].contour).toBe(c1); - expect(result[1].index).toBe(1); - - expect(result[2].point).toBe(p3); - expect(result[2].contour).toBe(c1); - expect(result[2].index).toBe(2); - - expect(result[3].point).toBe(p4); - expect(result[3].contour).toBe(c2); - expect(result[3].index).toBe(0); - }); - - it("yields nothing for empty glyph", () => { - const result = [...Glyphs.points(makeGlyph([]))]; - expect(result).toHaveLength(0); - }); - - it("yields nothing for glyph with empty contours", () => { - const empty = makeContour("e1", []); - const result = [...Glyphs.points(makeGlyph([empty]))]; - expect(result).toHaveLength(0); - }); - }); - - describe("getPointAt", () => { - it("finds a point within radius", () => { - const result = Glyphs.getPointAt(glyph, { x: 1, y: 1 }, 5); - expect(result).toBe(p1); - }); - - it("returns null when no point is close enough", () => { - const result = Glyphs.getPointAt(glyph, { x: 50, y: 50 }, 5); - expect(result).toBeNull(); - }); - - it("returns the first matching point", () => { - const result = Glyphs.getPointAt(glyph, { x: 99, y: 1 }, 5); - expect(result).toBe(p2); - }); - }); -}); diff --git a/packages/font/src/Glyph.ts b/packages/font/src/Glyph.ts deleted file mode 100644 index a565371f..00000000 --- a/packages/font/src/Glyph.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Pure utility functions for querying glyph structures. - * - * The `Glyphs` namespace provides point lookup, iteration, and spatial - * queries over the immutable {@link Glyph} domain type. Every function is - * stateless -- it reads the glyph and returns a result without mutation. - * - * @module - */ -import type { Point, Contour, Glyph, PointId, ContourId, Point2D } from "@shift/types"; - -/** - * A point together with the contour it belongs to and its index within that - * contour's `points` array. Returned by iteration and lookup helpers. - */ -export interface PointInContour { - point: Point; - contour: Contour; - index: number; -} - -export const Glyphs = { - /** - * Locate a point by ID across all contours. - * @returns The point, its parent contour, and index, or `null` if not found. - */ - findPoint( - glyph: Glyph, - pointId: PointId, - ): { point: Point; contour: Contour; index: number } | null { - for (const contour of glyph.contours) { - const index = contour.points.findIndex((p) => p.id === pointId); - if (index !== -1) { - const point = contour.points[index]; - if (point) { - return { point, contour, index }; - } - } - } - return null; - }, - - findContour(glyph: Glyph, contourId: ContourId): Contour | undefined { - return glyph.contours.find((c) => c.id === contourId); - }, - - /** Lazily iterate every point in the glyph, yielding {@link PointInContour} tuples. */ - *points(glyph: Glyph): Generator { - for (const contour of glyph.contours) { - for (const [i, point] of contour.points.entries()) { - yield { point, contour, index: i }; - } - } - }, - - /** Return all points whose IDs appear in `pointIds`. Order follows contour iteration order. */ - findPoints(glyph: Glyph, pointIds: Iterable): Point[] { - const idSet = new Set(pointIds); - const result: Point[] = []; - for (const { point } of Glyphs.points(glyph)) { - if (idSet.has(point.id)) { - result.push(point); - } - } - return result; - }, - - getAllPoints(glyph: Glyph): Point[] { - return Array.from(Glyphs.points(glyph), ({ point }) => point); - }, - - /** - * Find the first point within `radius` of `pos` (linear scan). - * @returns The matching point, or `null` if none is close enough. - * - * Hot path — called on every pointer-move from the cursor computed. - * Iterates contour points inline (no `points()` generator allocation) - * and uses squared-distance comparison (no `Math.sqrt`). - */ - getPointAt(glyph: Glyph, pos: Point2D, radius: number): Point | null { - const r2 = radius * radius; - const px = pos.x; - const py = pos.y; - for (const contour of glyph.contours) { - for (const point of contour.points) { - const dx = point.x - px; - const dy = point.y - py; - if (dx * dx + dy * dy < r2) return point; - } - } - return null; - }, -} as const; diff --git a/packages/font/src/GlyphEquality.test.ts b/packages/font/src/GlyphEquality.test.ts deleted file mode 100644 index fac17bc3..00000000 --- a/packages/font/src/GlyphEquality.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { areGlyphSnapshotsEqual } from "./GlyphEquality"; -import type { AnchorId, ContourId, GlyphSnapshot, PointId } from "@shift/types"; - -function cloneGlyphSnapshot(glyph: GlyphSnapshot): GlyphSnapshot { - return { - ...glyph, - contours: glyph.contours.map((contour) => ({ - ...contour, - points: contour.points.map((point) => ({ ...point })), - })), - anchors: glyph.anchors.map((anchor) => ({ ...anchor })), - compositeContours: glyph.compositeContours.map((contour) => ({ - ...contour, - points: contour.points.map((point) => ({ ...point })), - })), - }; -} - -function makeGlyphSnapshot(): GlyphSnapshot { - return { - unicode: 65, - name: "A", - xAdvance: 600, - activeContourId: "c1" as ContourId, - contours: [ - { - id: "c1" as ContourId, - closed: true, - points: [ - { id: "p1" as PointId, x: 0, y: 0, pointType: "onCurve", smooth: false }, - { id: "p2" as PointId, x: 120, y: 0, pointType: "offCurve", smooth: false }, - { id: "p3" as PointId, x: 120, y: 300, pointType: "onCurve", smooth: true }, - ], - }, - ], - anchors: [{ id: "a1" as AnchorId, name: "top", x: 60, y: 300 }], - compositeContours: [ - { - closed: false, - points: [ - { x: 10, y: 20, pointType: "onCurve", smooth: false }, - { x: 30, y: 40, pointType: "offCurve", smooth: false }, - ], - }, - ], - }; -} - -describe("areGlyphSnapshotsEqual", () => { - it("returns true for the same reference", () => { - const glyph = makeGlyphSnapshot(); - expect(areGlyphSnapshotsEqual(glyph, glyph)).toBe(true); - }); - - it("returns true for equal snapshots", () => { - const left = makeGlyphSnapshot(); - const right = cloneGlyphSnapshot(left); - expect(areGlyphSnapshotsEqual(left, right)).toBe(true); - }); - - it("returns false when top-level glyph metadata differs", () => { - const left = makeGlyphSnapshot(); - const right = cloneGlyphSnapshot(left); - right.xAdvance += 10; - expect(areGlyphSnapshotsEqual(left, right)).toBe(false); - }); - - it("returns false when contour point geometry differs", () => { - const left = makeGlyphSnapshot(); - const right = cloneGlyphSnapshot(left); - right.contours[0].points[1].x += 1; - expect(areGlyphSnapshotsEqual(left, right)).toBe(false); - }); - - it("returns false when anchors differ", () => { - const left = makeGlyphSnapshot(); - const right = cloneGlyphSnapshot(left); - right.anchors[0].name = "bottom"; - expect(areGlyphSnapshotsEqual(left, right)).toBe(false); - }); - - it("returns false when composite contours differ", () => { - const left = makeGlyphSnapshot(); - const right = cloneGlyphSnapshot(left); - right.compositeContours[0].points[0].y += 1; - expect(areGlyphSnapshotsEqual(left, right)).toBe(false); - }); -}); diff --git a/packages/font/src/GlyphEquality.ts b/packages/font/src/GlyphEquality.ts deleted file mode 100644 index 61f23995..00000000 --- a/packages/font/src/GlyphEquality.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { GlyphSnapshot } from "@shift/types"; - -function areArraysEqual( - a: readonly T[], - b: readonly T[], - areItemsEqual: (left: T, right: T) => boolean, -): boolean { - if (a.length !== b.length) return false; - - for (let i = 0; i < a.length; i++) { - if (!areItemsEqual(a[i], b[i])) { - return false; - } - } - - return true; -} - -function arePointSnapshotsEqual( - a: GlyphSnapshot["contours"][number]["points"][number], - b: GlyphSnapshot["contours"][number]["points"][number], -): boolean { - return ( - a.id === b.id && - a.x === b.x && - a.y === b.y && - a.pointType === b.pointType && - a.smooth === b.smooth - ); -} - -function areContourSnapshotsEqual( - a: GlyphSnapshot["contours"][number], - b: GlyphSnapshot["contours"][number], -): boolean { - return ( - a.id === b.id && - a.closed === b.closed && - areArraysEqual(a.points, b.points, arePointSnapshotsEqual) - ); -} - -function areAnchorSnapshotsEqual( - a: GlyphSnapshot["anchors"][number], - b: GlyphSnapshot["anchors"][number], -): boolean { - return a.id === b.id && a.name === b.name && a.x === b.x && a.y === b.y; -} - -function areRenderPointSnapshotsEqual( - a: GlyphSnapshot["compositeContours"][number]["points"][number], - b: GlyphSnapshot["compositeContours"][number]["points"][number], -): boolean { - return a.x === b.x && a.y === b.y && a.pointType === b.pointType && a.smooth === b.smooth; -} - -function areRenderContourSnapshotsEqual( - a: GlyphSnapshot["compositeContours"][number], - b: GlyphSnapshot["compositeContours"][number], -): boolean { - return a.closed === b.closed && areArraysEqual(a.points, b.points, areRenderPointSnapshotsEqual); -} - -/** - * Compare two glyph snapshots by value using domain-specific field checks. - * - * This avoids generic deep-equal traversal while still ensuring drag commits - * only produce history entries when a glyph actually changed. - */ -export function areGlyphSnapshotsEqual(a: GlyphSnapshot, b: GlyphSnapshot): boolean { - if (a === b) return true; - - return ( - a.unicode === b.unicode && - a.name === b.name && - a.xAdvance === b.xAdvance && - a.activeContourId === b.activeContourId && - areArraysEqual(a.contours, b.contours, areContourSnapshotsEqual) && - areArraysEqual(a.anchors, b.anchors, areAnchorSnapshotsEqual) && - areArraysEqual(a.compositeContours, b.compositeContours, areRenderContourSnapshotsEqual) - ); -} diff --git a/packages/font/src/index.ts b/packages/font/src/index.ts deleted file mode 100644 index 45fb1f0c..00000000 --- a/packages/font/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { Contours, type PointWithNeighbors } from "./Contour"; -export { Glyphs, type PointInContour } from "./Glyph"; -export { areGlyphSnapshotsEqual } from "./GlyphEquality"; -export { - deriveGlyphTightBounds, - deriveGlyphXBounds, - parseContourSegments, - segmentToCurve, - type SegmentPointGeometry, - type SegmentContourLike, - type SegmentGeometry, - type LineSegmentGeometry, - type QuadSegmentGeometry, - type CubicSegmentGeometry, -} from "./GlyphGeometry"; diff --git a/packages/geo/docs/DOCS.md b/packages/geo/docs/DOCS.md index 6d3c30d0..cac1f0f2 100644 --- a/packages/geo/docs/DOCS.md +++ b/packages/geo/docs/DOCS.md @@ -8,7 +8,7 @@ Lightweight 2D geometry library providing pure-functional vector math, bezier cu **Architecture Invariant:** `Mat` is the sole exception to the pure-function rule. It is a mutable class whose instance methods (`multiply`, `translate`, `scale`, `rotate`, `invert`) mutate in place and return `this` for chaining. Static factory methods (`Identity`, `Translate`, `Scale`, `Rotate`, `Compose`) return new instances. WHY: matrix chaining is a hot path where allocation matters; the mutable API mirrors Canvas2D's `setTransform`. -**Architecture Invariant:** All geometry operates on `Point2D` = `{ x: number; y: number }` from `@shift/types`. Functions accept any object with `x` and `y` properties -- no wrapper class required. WHY: avoids forcing callers to convert between vector types; any `{ x, y }` works. +**Architecture Invariant:** All geometry operates on `Point2D` = `{ x: number; y: number }` from `@shift/geo`. Functions accept any object with `x` and `y` properties -- no wrapper class required. WHY: avoids forcing callers to convert between vector types; any `{ x, y }` works. **Architecture Invariant:** Floating-point comparisons in `Vec2` use `EPSILON = 1e-10` (module-level constant in Vec2.ts). `Curve` uses separate constants: `NEWTON_TOLERANCE = 1e-6` and `CURVE_SUBDIVISIONS = 32`. These are not configurable at runtime. WHY: consistent precision across all call sites; epsilon tuning should be a deliberate library-wide change, not per-call. @@ -19,7 +19,7 @@ Lightweight 2D geometry library providing pure-functional vector math, bezier cu ``` src/ index.ts -- public re-exports for all symbols - types.ts -- re-exports Point2D, Rect2D from @shift/types + types.ts -- Point2D and Rect2D geometry primitives Vec2.ts -- 2D vector operations namespace (~50 functions) Bounds.ts -- axis-aligned bounding box (interface + namespace) Curve.ts -- line/quadratic/cubic bezier primitives with hit-testing @@ -29,8 +29,8 @@ src/ ## Key Types -- `Point2D` -- `{ x: number; y: number }` (re-exported from `@shift/types`). The universal 2D coordinate type used by every function in the library. -- `Rect2D` -- `{ x, y, width, height, left, top, right, bottom }` (re-exported from `@shift/types`). Returned by `Bounds.toRect` and `Polygon.boundingRect`. +- `Point2D` -- `{ x: number; y: number }`. The universal 2D coordinate type used by every function in the library. +- `Rect2D` -- `{ x, y, width, height, left, top, right, bottom }`. Returned by `Bounds.toRect` and `Polygon.boundingRect`. - `Bounds` -- `{ min: Point2D; max: Point2D }` axis-aligned bounding box. Both an interface and a namespace. - `LineCurve` -- `{ type: "line"; p0; p1 }`. Straight segment between two points. - `QuadraticCurve` -- `{ type: "quadratic"; p0; c; p1 }`. One control point. @@ -42,7 +42,7 @@ src/ ## How it works -**Vec2** is the core building block. It provides construction (`create`, `zero`, `fromAngle`), arithmetic (`add`, `sub`, `scale`, `dot`, `cross`), geometry (`normalize`, `project`, `reflect`, `rotate`, `rotateAround`, `mirror`, `perp`), interpolation (`lerp`, `lerpInt`), predicates (`equals`, `isParallel`, `isWithin`), and snapping (`constrainToAxis`, `snapToAngle`, `snapAngleWithHysteresis`). Every function takes and returns plain `{ x, y }` objects. +**Vec2** is the core building block. It provides construction (`create`, `zero`, `fromAngle`), arithmetic (`add`, `sub`, `scale`, `dot`, `cross`), geometry (`normalize`, `project`, `reflect`, `rotate`, `rotateAround`, `mirror`, `perp`), interpolation (`lerp`, `lerpInt`), and predicates (`equals`, `isParallel`, `isWithin`). Every function takes and returns plain `{ x, y }` objects. **Curve** implements bezier math with a discriminated-union pattern. You construct curves with `Curve.line`, `Curve.quadratic`, or `Curve.cubic`, then pass them to polymorphic functions: `pointAt(curve, t)`, `tangentAt`, `normalAt`, `closestPoint`, `bounds`, `splitAt`, `length`, `sample`. Closest-point queries use a two-phase algorithm: coarse subdivision scan (32 samples) followed by Newton-Raphson refinement (up to 8 iterations at 1e-6 tolerance). `quadraticToCubic` performs lossless degree elevation. @@ -50,7 +50,7 @@ src/ **Polygon** uses the shoelace formula for `signedArea` and winding-direction detection (`isClockwise`, `isCounterClockwise`). -**Mat** is a 3x2 affine matrix (implicit bottom row `[0, 0, 1]`). Instance methods mutate and chain; static methods create new matrices. `Mat.fromDecomposed` / `Mat.toDecomposed` round-trip with `DecomposedTransform` (from `@shift/types`) for the UI's translate/rotate/scale/skew controls. `Mat.applyToPoint` transforms a `Point2D`. +**Mat** is a 3x2 affine matrix (implicit bottom row `[0, 0, 1]`). Instance methods mutate and chain; static methods create new matrices. `Mat.fromDecomposed` / `Mat.toDecomposed` round-trip with `DecomposedTransform` for the UI's translate/rotate/scale/skew controls. `Mat.applyToPoint` transforms a `Point2D`. ## Workflow recipes @@ -95,7 +95,7 @@ pnpm --filter @shift/geo typecheck # TypeScript type checking ## Related -- `@shift/types` -- defines `Point2D`, `Rect2D`, and `DecomposedTransform` used throughout this package -- `@shift/font` -- uses `Vec2`, `Curve`, and `Bounds` for glyph geometry and contour operations +- `@shift/types` -- defines shared branded IDs and bridge DTOs used by consumers +- `@shift/glyph-state` -- uses `Vec2`, `Curve`, and `Bounds` for glyph geometry and contour operations - `@shift/rules` -- uses `Vec2` and `Point2D` for constraint evaluation - `Transform` (in `apps/desktop`) -- uses `Mat` for selection transforms (rotate, scale, reflect) diff --git a/packages/geo/src/Mat.ts b/packages/geo/src/Mat.ts index b95e9315..9f9c4c1d 100644 --- a/packages/geo/src/Mat.ts +++ b/packages/geo/src/Mat.ts @@ -1,6 +1,17 @@ -import type { DecomposedTransform } from "@shift/types"; import type { Point2D } from "./types"; +export interface DecomposedTransform { + readonly translateX: number; + readonly translateY: number; + readonly rotation: number; + readonly scaleX: number; + readonly scaleY: number; + readonly skewX: number; + readonly skewY: number; + readonly tCenterX: number; + readonly tCenterY: number; +} + /** * 2D Affine Transformation Matrix (3x2 representation) * diff --git a/packages/geo/src/Vec2.test.ts b/packages/geo/src/Vec2.test.ts index d17925f9..32ef0796 100644 --- a/packages/geo/src/Vec2.test.ts +++ b/packages/geo/src/Vec2.test.ts @@ -264,175 +264,4 @@ describe("Vec2", () => { expect(Vec2.round({ x: 1.4, y: 2.6 })).toEqual({ x: 1, y: 3 }); }); }); - - describe("snapping utilities", () => { - describe("constrainToAxis", () => { - it("constrains to horizontal when x is dominant", () => { - expect(Vec2.constrainToAxis({ x: 10, y: 3 })).toEqual({ x: 10, y: 0 }); - }); - - it("constrains to vertical when y is dominant", () => { - expect(Vec2.constrainToAxis({ x: 3, y: 10 })).toEqual({ x: 0, y: 10 }); - }); - - it("constrains to horizontal when equal", () => { - expect(Vec2.constrainToAxis({ x: 5, y: 5 })).toEqual({ x: 5, y: 0 }); - }); - - it("handles negative values", () => { - expect(Vec2.constrainToAxis({ x: -10, y: 3 })).toEqual({ x: -10, y: 0 }); - expect(Vec2.constrainToAxis({ x: 3, y: -10 })).toEqual({ x: 0, y: -10 }); - }); - }); - - describe("snapAngle", () => { - it("snaps to nearest 45 degrees by default", () => { - expect(Vec2.snapAngle(Math.PI / 16)).toBeCloseTo(0); - expect(Vec2.snapAngle(Math.PI / 3)).toBeCloseTo(Math.PI / 4); - expect(Vec2.snapAngle(Math.PI / 2)).toBeCloseTo(Math.PI / 2); - }); - - it("snaps to 15 degrees when specified", () => { - const inc = Math.PI / 12; - expect(Vec2.snapAngle(Math.PI / 10, inc)).toBeCloseTo(inc); - expect(Vec2.snapAngle(Math.PI / 48, inc)).toBeCloseTo(0); - }); - - it("handles negative angles", () => { - expect(Vec2.snapAngle(-Math.PI / 16)).toBeCloseTo(0); - expect(Vec2.snapAngle(-Math.PI / 3)).toBeCloseTo(-Math.PI / 4); - }); - }); - - describe("snapToAngle", () => { - it("snaps vector to 45-degree increments preserving length", () => { - const v = { x: 10, y: 3 }; - const snapped = Vec2.snapToAngle(v); - expect(Vec2.angle(snapped)).toBeCloseTo(0); - expect(Vec2.len(snapped)).toBeCloseTo(Vec2.len(v)); - }); - - it("handles zero vector", () => { - expect(Vec2.snapToAngle({ x: 0, y: 0 })).toEqual({ x: 0, y: 0 }); - }); - - it("snaps to custom angle increments", () => { - const v = { x: 10, y: 2 }; - const snapped = Vec2.snapToAngle(v, Math.PI / 12); - const snappedAngle = Vec2.angle(snapped); - expect(snappedAngle).toBeCloseTo(Math.PI / 12); - expect(Vec2.len(snapped)).toBeCloseTo(Vec2.len(v)); - }); - - it("snaps vector pointing in negative direction", () => { - const v = { x: -10, y: 3 }; - const snapped = Vec2.snapToAngle(v); - expect(Vec2.angle(snapped)).toBeCloseTo(Math.PI); - expect(Vec2.len(snapped)).toBeCloseTo(Vec2.len(v)); - }); - }); - - describe("snapAngleWithHysteresis", () => { - it("snaps to nearest increment when no previous snap", () => { - expect(Vec2.snapAngleWithHysteresis(Math.PI / 16, null)).toBeCloseTo(0); - expect(Vec2.snapAngleWithHysteresis(Math.PI / 3, null)).toBeCloseTo(Math.PI / 4); - }); - - it("sticks to previous snap within threshold", () => { - const previousSnapped = 0; - const smallOffset = (Math.PI / 4) * 0.3; - expect(Vec2.snapAngleWithHysteresis(smallOffset, previousSnapped)).toBeCloseTo(0); - }); - - it("breaks free when exceeding threshold", () => { - const previousSnapped = 0; - const largeOffset = (Math.PI / 4) * 0.5; - expect(Vec2.snapAngleWithHysteresis(largeOffset, previousSnapped)).toBeCloseTo(Math.PI / 4); - }); - - it("respects custom hysteresis factor", () => { - const previousSnapped = 0; - const offset = (Math.PI / 4) * 0.3; - expect(Vec2.snapAngleWithHysteresis(offset, previousSnapped, Math.PI / 4, 0.5)).toBeCloseTo( - 0, - ); - expect(Vec2.snapAngleWithHysteresis(offset, previousSnapped, Math.PI / 4, 0.2)).toBeCloseTo( - 0, - ); - const largerOffset = (Math.PI / 4) * 0.6; - expect( - Vec2.snapAngleWithHysteresis(largerOffset, previousSnapped, Math.PI / 4, 0.5), - ).toBeCloseTo(Math.PI / 4); - }); - }); - - describe("snapToAngleWithHysteresis", () => { - it("returns snapped position and angle", () => { - const v = { x: 10, y: 3 }; - const result = Vec2.snapToAngleWithHysteresis(v, null); - expect(result.snappedAngle).toBeCloseTo(0); - expect(Vec2.len(result.position)).toBeCloseTo(Vec2.len(v)); - }); - - it("handles zero vector", () => { - const result = Vec2.snapToAngleWithHysteresis({ x: 0, y: 0 }, null); - expect(result.position).toEqual({ x: 0, y: 0 }); - expect(result.snappedAngle).toBe(0); - }); - - it("sticks to previous angle within threshold", () => { - const previousAngle = 0; - const v = Vec2.fromAngle((Math.PI / 4) * 0.3); - const scaled = Vec2.scale(v, 10); - const result = Vec2.snapToAngleWithHysteresis(scaled, previousAngle); - expect(result.snappedAngle).toBeCloseTo(0); - }); - - it("breaks free from previous angle when exceeding threshold", () => { - const previousAngle = 0; - const v = Vec2.fromAngle((Math.PI / 4) * 0.5); - const scaled = Vec2.scale(v, 10); - const result = Vec2.snapToAngleWithHysteresis(scaled, previousAngle); - expect(result.snappedAngle).toBeCloseTo(Math.PI / 4); - }); - }); - - describe("constrainToAxisWithHysteresis", () => { - it("picks dominant axis when no previous axis", () => { - const result1 = Vec2.constrainToAxisWithHysteresis({ x: 10, y: 3 }, null); - expect(result1.axis).toBe("x"); - expect(result1.delta).toEqual({ x: 10, y: 0 }); - - const result2 = Vec2.constrainToAxisWithHysteresis({ x: 3, y: 10 }, null); - expect(result2.axis).toBe("y"); - expect(result2.delta).toEqual({ x: 0, y: 10 }); - }); - - it("sticks to previous axis within threshold", () => { - const result = Vec2.constrainToAxisWithHysteresis({ x: 5, y: 6 }, "x"); - expect(result.axis).toBe("x"); - expect(result.delta).toEqual({ x: 5, y: 0 }); - }); - - it("switches axis when strongly dominant in other direction", () => { - const result = Vec2.constrainToAxisWithHysteresis({ x: 2, y: 10 }, "x"); - expect(result.axis).toBe("y"); - expect(result.delta).toEqual({ x: 0, y: 10 }); - }); - - it("respects custom threshold", () => { - const result1 = Vec2.constrainToAxisWithHysteresis({ x: 3, y: 7 }, "x", 0.5); - expect(result1.axis).toBe("y"); - - const result2 = Vec2.constrainToAxisWithHysteresis({ x: 3, y: 7 }, "x", 0.8); - expect(result2.axis).toBe("x"); - }); - - it("handles negative values", () => { - const result = Vec2.constrainToAxisWithHysteresis({ x: -10, y: 3 }, null); - expect(result.axis).toBe("x"); - expect(result.delta).toEqual({ x: -10, y: 0 }); - }); - }); - }); }); diff --git a/packages/geo/src/Vec2.ts b/packages/geo/src/Vec2.ts index 88c3f687..a486be0b 100644 --- a/packages/geo/src/Vec2.ts +++ b/packages/geo/src/Vec2.ts @@ -493,132 +493,4 @@ export const Vec2 = { fromArray(arr: [number, number]): Point2D { return { x: arr[0], y: arr[1] }; }, - - // ============================================ - // Snapping - // ============================================ - - /** - * Constrain a delta vector to the dominant axis (horizontal or vertical). - * Returns a vector with only the x or y component, whichever has larger absolute value. - */ - constrainToAxis(delta: Point2D): Point2D { - if (Math.abs(delta.x) >= Math.abs(delta.y)) { - return { x: delta.x, y: 0 }; - } - return { x: 0, y: delta.y }; - }, - - /** - * Snap an angle to the nearest increment. - * @param angle - The angle in radians - * @param increment - The snap increment in radians (default: π/4 = 45°) - */ - snapAngle(angle: number, increment: number = Math.PI / 4): number { - return Math.round(angle / increment) * increment; - }, - - /** - * Snap a vector to the nearest angle increment while preserving its length. - * @param v - The vector to snap - * @param increment - The angle snap increment in radians (default: π/4 = 45°) - */ - snapToAngle(v: Point2D, increment: number = Math.PI / 4): Point2D { - const len = Vec2.len(v); - if (len < EPSILON) return { x: 0, y: 0 }; - const angle = Vec2.angle(v); - const snappedAngle = Math.round(angle / increment) * increment; - return { x: len * Math.cos(snappedAngle), y: len * Math.sin(snappedAngle) }; - }, - - /** - * Snap an angle with hysteresis - sticks to previous snap until threshold exceeded. - * @param angle - The angle in radians - * @param previousSnapped - The previously snapped angle (null if none) - * @param increment - The snap increment in radians (default: π/4 = 45°) - * @param hysteresisFactor - Fraction of increment needed to break free (default: 0.4 = 40%) - */ - snapAngleWithHysteresis( - angle: number, - previousSnapped: number | null, - increment: number = Math.PI / 4, - hysteresisFactor: number = 0.4, - ): number { - if (previousSnapped !== null) { - const threshold = increment * hysteresisFactor; - if (Math.abs(angle - previousSnapped) <= threshold) { - return previousSnapped; - } - } - return Math.round(angle / increment) * increment; - }, - - /** - * Snap a vector to angle increments with hysteresis. - * Returns both the snapped position and the snapped angle (for tracking). - * @param v - The vector to snap - * @param previousSnapped - The previously snapped angle (null if none) - * @param increment - The angle snap increment in radians (default: π/4 = 45°) - * @param hysteresisFactor - Fraction of increment needed to break free (default: 0.4 = 40%) - */ - snapToAngleWithHysteresis( - v: Point2D, - previousSnapped: number | null, - increment: number = Math.PI / 4, - hysteresisFactor: number = 0.4, - ): { position: Point2D; snappedAngle: number } { - const len = Vec2.len(v); - if (len < EPSILON) return { position: { x: 0, y: 0 }, snappedAngle: 0 }; - - const rawAngle = Vec2.angle(v); - const snappedAngle = Vec2.snapAngleWithHysteresis( - rawAngle, - previousSnapped, - increment, - hysteresisFactor, - ); - - return { - position: { x: len * Math.cos(snappedAngle), y: len * Math.sin(snappedAngle) }, - snappedAngle, - }; - }, - - /** - * Constrain a delta to an axis with hysteresis - sticks to previous axis until threshold exceeded. - * @param delta - The delta vector to constrain - * @param previousAxis - The previously constrained axis (null if none) - * @param threshold - Ratio (0-1) of dominance needed to switch axes (default: 0.7 = 70%) - */ - constrainToAxisWithHysteresis( - delta: Point2D, - previousAxis: "x" | "y" | null, - threshold: number = 0.7, - ): { delta: Point2D; axis: "x" | "y" } { - const absX = Math.abs(delta.x); - const absY = Math.abs(delta.y); - - let axis: "x" | "y"; - - if (previousAxis === null) { - axis = absX >= absY ? "x" : "y"; - } else { - const total = absX + absY + EPSILON; - const xRatio = absX / total; - const yRatio = absY / total; - - if (previousAxis === "x" && yRatio > threshold) { - axis = "y"; - } else if (previousAxis === "y" && xRatio > threshold) { - axis = "x"; - } else { - axis = previousAxis; - } - } - - return { - delta: axis === "x" ? { x: delta.x, y: 0 } : { x: 0, y: delta.y }, - axis, - }; - }, } as const; diff --git a/packages/geo/src/index.ts b/packages/geo/src/index.ts index 26e85f3b..898c845f 100644 --- a/packages/geo/src/index.ts +++ b/packages/geo/src/index.ts @@ -45,4 +45,4 @@ export { Polygon } from "./Polygon"; export type { LineCurve, QuadraticCurve, CubicCurve, CurveType, ClosestPoint } from "./Curve"; // Matrix transformations -export { Mat, type MatModel } from "./Mat"; +export { Mat, type DecomposedTransform, type MatModel } from "./Mat"; diff --git a/packages/geo/src/types.ts b/packages/geo/src/types.ts index a00fab7d..b582593a 100644 --- a/packages/geo/src/types.ts +++ b/packages/geo/src/types.ts @@ -1 +1,11 @@ -export type { Point2D, Rect2D } from "@shift/types"; +export type Point2D = { x: number; y: number }; +export type Rect2D = { + x: number; + y: number; + width: number; + height: number; + left: number; + top: number; + right: number; + bottom: number; +}; diff --git a/packages/glyph-state/docs/DOCS.md b/packages/glyph-state/docs/DOCS.md new file mode 100644 index 00000000..a657a1df --- /dev/null +++ b/packages/glyph-state/docs/DOCS.md @@ -0,0 +1,50 @@ +# Glyph State + +Pure readers and geometry helpers for `GlyphStructure + Float64Array` glyph state. + +## Architecture Invariants + +- **Architecture Invariant:** This package has no editor state, signals, command history, bridge calls, source/session selection, DOM APIs, or mutation ownership. It only interprets already-provided glyph state. +- **Architecture Invariant:** `GlyphStateGeometry` is a lazy reader over `GlyphStructure + values`. The renderer may cache an instance per reactive state update; rendering paths should not rebuild it inside inner draw loops. +- **Architecture Invariant:** The flat values layout matches `shift-wire`: xAdvance, contour point positions, anchor positions, then component transforms. Any layout change in Rust must update `GlyphStateGeometry`, `Contour`, `Anchor`, and `Component` together. +- **Architecture Invariant:** Segment parsing is structural. Two on-curve points produce a line; onCurve/offCurve/onCurve produces a quad; onCurve/offCurve/offCurve/onCurve produces a cubic. Other patterns are skipped by the parser. + +## Codemap + +``` +packages/glyph-state/src/ + index.ts -- public API barrel + GlyphStateGeometry.ts -- state reader, bounds, sidebearings, position packing + Contour.ts -- contour reader, point access, neighbors, selection bounds + Anchor.ts -- anchor reader and anchor value offsets + Component.ts -- component reader and decomposed transform matrix + Segment.ts -- id-aware segment class, hit testing, curve conversion + GlyphGeometry.ts -- low-level segment parser and curve conversion helpers +``` + +## Key Types + +- **`GlyphStateGeometry`** -- immutable reader over `GlyphStructure + Float64Array`; exposes `xAdvance`, `contours`, `anchors`, `components`, `allPoints`, `bounds`, `sidebearings`, lookup helpers, preview value updates, and packed position updates. +- **`Contour`** -- reader for one contour's point records and point coordinates. Exposes endpoint/on-curve queries, wrapped `pointAt`, `withNeighbors`, `segments`, `selectionBounds`, and `canClose`. +- **`Anchor`** -- reader for one anchor's metadata and coordinates. +- **`Component`** -- reader for one component's base glyph and decomposed transform; exposes a simple affine matrix for outline composition. +- **`Segment`** -- id-aware line/quad/cubic wrapper with `id`, endpoint/control accessors, `bounds`, `toCurve`, `splitAt`, and `hitTest`. +- **`GlyphPosition` / `GlyphPositionTarget`** -- point/anchor position records used for source edit previews and `setPositions` packing. + +## How It Fits + +Rust owns loading, persistence, edit sessions, ID allocation, boolean operations, and authoritative mutation. The bridge returns `GlyphStructure + values` for a source. This package turns that state into useful geometry. The renderer wraps these readers in signals and editor APIs. + +```ts +const geometry = new GlyphStateGeometry(state.structure, state.values); +const point = geometry.point(pointId); +const bounds = geometry.bounds; +const packed = GlyphStateGeometry.packPositionUpdates(positions); +``` + +Renderer code should keep using cached `GlyphStateGeometry` instances from the model layer. Creating a geometry object is fine on source/state changes; doing it per segment draw or per hit-test candidate is not. + +## Verification + +- `pnpm --filter @shift/glyph-state test` +- `pnpm --filter @shift/glyph-state typecheck` diff --git a/packages/font/package.json b/packages/glyph-state/package.json similarity index 80% rename from packages/font/package.json rename to packages/glyph-state/package.json index aacf57b9..13826922 100644 --- a/packages/font/package.json +++ b/packages/glyph-state/package.json @@ -1,8 +1,8 @@ { - "name": "@shift/font", + "name": "@shift/glyph-state", "version": "0.0.1", "private": true, - "description": "Font domain operations for glyphs and contours", + "description": "Pure readers and geometry helpers for GlyphStructure + values state", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/glyph-state/src/Anchor.ts b/packages/glyph-state/src/Anchor.ts new file mode 100644 index 00000000..6a2d5bd2 --- /dev/null +++ b/packages/glyph-state/src/Anchor.ts @@ -0,0 +1,47 @@ +import type { AnchorData, AnchorId, GlyphStructure } from "@shift/types"; + +export class Anchor { + readonly #data: AnchorData; + readonly #values: Float64Array; + readonly #cursor: number; + + constructor(data: AnchorData, values: Float64Array, cursor: number) { + this.#data = data; + this.#values = values; + this.#cursor = cursor; + } + + static fromStructure(structure: GlyphStructure, values: Float64Array): readonly Anchor[] { + let cursor = 1; + for (const contour of structure.contours) cursor += contour.points.length * 2; + + return structure.anchors.map((anchor, index) => new Anchor(anchor, values, cursor + index * 2)); + } + + static valueOffsets(structure: GlyphStructure): Map { + const offsets = new Map(); + let cursor = 1; + for (const contour of structure.contours) cursor += contour.points.length * 2; + for (const anchor of structure.anchors) { + offsets.set(anchor.id, cursor); + cursor += 2; + } + return offsets; + } + + get id(): AnchorId { + return this.#data.id; + } + + get name(): string | undefined { + return this.#data.name; + } + + get x(): number { + return this.#values[this.#cursor]; + } + + get y(): number { + return this.#values[this.#cursor + 1]; + } +} diff --git a/packages/glyph-state/src/Component.ts b/packages/glyph-state/src/Component.ts new file mode 100644 index 00000000..3a35647a --- /dev/null +++ b/packages/glyph-state/src/Component.ts @@ -0,0 +1,80 @@ +import type { ComponentData, ComponentId, GlyphName, GlyphStructure } from "@shift/types"; + +export interface ComponentTransform { + readonly translateX: number; + readonly translateY: number; + readonly rotation: number; + readonly scaleX: number; + readonly scaleY: number; + readonly skewX: number; + readonly skewY: number; + readonly tCenterX: number; + readonly tCenterY: number; +} + +export type Matrix = { xx: number; xy: number; yx: number; yy: number; dx: number; dy: number }; + +export class Component { + readonly #data: ComponentData; + readonly #values: Float64Array; + readonly #cursor: number; + + constructor(data: ComponentData, values: Float64Array, cursor: number) { + this.#data = data; + this.#values = values; + this.#cursor = cursor; + } + + static fromStructure(structure: GlyphStructure, values: Float64Array): readonly Component[] { + let cursor = 1; + for (const contour of structure.contours) cursor += contour.points.length * 2; + cursor += structure.anchors.length * 2; + + return structure.components.map( + (component, index) => new Component(component, values, cursor + index * 9), + ); + } + + get id(): ComponentId { + return this.#data.id; + } + + get baseGlyphName(): GlyphName { + return this.#data.baseGlyphName; + } + + get transform(): ComponentTransform { + return { + translateX: this.#values[this.#cursor], + translateY: this.#values[this.#cursor + 1], + rotation: this.#values[this.#cursor + 2], + scaleX: this.#values[this.#cursor + 3], + scaleY: this.#values[this.#cursor + 4], + skewX: this.#values[this.#cursor + 5], + skewY: this.#values[this.#cursor + 6], + tCenterX: this.#values[this.#cursor + 7], + tCenterY: this.#values[this.#cursor + 8], + }; + } + + get matrix(): Matrix { + return decomposedToMatrix(this.transform); + } +} + +function decomposedToMatrix(t: ComponentTransform): Matrix { + const cosR = Math.cos((t.rotation * Math.PI) / 180); + const sinR = Math.sin((t.rotation * Math.PI) / 180); + const tanSx = Math.tan((t.skewX * Math.PI) / 180); + const tanSy = Math.tan((t.skewY * Math.PI) / 180); + + const xx = t.scaleX * cosR + t.scaleY * tanSx * sinR; + const xy = t.scaleX * sinR - t.scaleY * tanSx * cosR; + const yx = t.scaleY * -sinR + t.scaleX * tanSy * cosR; + const yy = t.scaleY * cosR + t.scaleX * tanSy * sinR; + + const dx = t.translateX + t.tCenterX - (xx * t.tCenterX + yx * t.tCenterY); + const dy = t.translateY + t.tCenterY - (xy * t.tCenterX + yy * t.tCenterY); + + return { xx, xy, yx, yy, dx, dy }; +} diff --git a/packages/glyph-state/src/Contour.test.ts b/packages/glyph-state/src/Contour.test.ts new file mode 100644 index 00000000..f1be1a5c --- /dev/null +++ b/packages/glyph-state/src/Contour.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { asContourId, asPointId, type ContourData } from "@shift/types"; +import { Contour } from "./Contour"; + +function contourData(closed = false): ContourData { + return { + id: asContourId("c1"), + closed, + points: [ + { id: asPointId("p1"), pointType: "onCurve", smooth: false }, + { id: asPointId("p2"), pointType: "onCurve", smooth: false }, + { id: asPointId("p3"), pointType: "onCurve", smooth: false }, + ], + }; +} + +function contour(closed = false): Contour { + return new Contour(contourData(closed), new Float64Array([500, 0, 0, 100, 0, 50, 100]), 1); +} + +describe("Contour", () => { + it("projects point data from the flat values buffer", () => { + const points = contour().points; + + expect(points.map((point) => [point.id, point.x, point.y])).toEqual([ + ["p1", 0, 0], + ["p2", 100, 0], + ["p3", 50, 100], + ]); + }); + + it("keeps endpoint and empty state derived from point order", () => { + const c = contour(); + + expect(c.firstPoint?.id).toBe("p1"); + expect(c.lastPoint?.id).toBe("p3"); + expect(c.isEmpty).toBe(false); + }); + + it("walks neighbors with open and closed contour semantics", () => { + const open = [...contour(false).withNeighbors()]; + const closed = [...contour(true).withNeighbors()]; + + expect(open[0].prev).toBeNull(); + expect(open[2].next).toBeNull(); + expect(closed[0].prev?.id).toBe("p3"); + expect(closed[2].next?.id).toBe("p1"); + }); + + it("parses segments and closed wrap-around segment", () => { + expect(contour(false).segments()).toHaveLength(2); + expect(contour(true).segments()).toHaveLength(3); + }); + + it("computes contour and selection bounds", () => { + const c = contour(true); + + expect(c.bounds?.min).toEqual({ x: 0, y: 0 }); + expect(c.bounds?.max).toEqual({ x: 100, y: 100 }); + expect(c.selectionBounds(new Set([asPointId("p1"), asPointId("p2")]))?.max.x).toBe(100); + }); + + it("detects whether an open contour can close near its first point", () => { + expect(contour(false).canClose({ x: 3, y: 4 }, 5)).toBe(true); + expect(contour(false).canClose({ x: 20, y: 20 }, 5)).toBe(false); + expect(contour(true).canClose({ x: 0, y: 0 }, 5)).toBe(false); + }); +}); diff --git a/packages/glyph-state/src/Contour.ts b/packages/glyph-state/src/Contour.ts new file mode 100644 index 00000000..6bec8bce --- /dev/null +++ b/packages/glyph-state/src/Contour.ts @@ -0,0 +1,157 @@ +import type { + ContourData, + ContourId, + GlyphStructure, + PointData, + PointId, + PointType, +} from "@shift/types"; +import { Bounds, type Bounds as BoundsType, type Point2D } from "@shift/geo"; +import { Segment } from "./Segment"; + +export interface Point extends PointData { + readonly x: number; + readonly y: number; +} + +export interface PointWithNeighbors { + readonly point: Point; + readonly prev: Point | null; + readonly next: Point | null; +} + +export class Contour { + readonly #data: ContourData; + readonly #values: Float64Array; + readonly #cursor: number; + + constructor(data: ContourData, values: Float64Array, cursor: number) { + this.#data = data; + this.#values = values; + this.#cursor = cursor; + } + + static fromStructure(structure: GlyphStructure, values: Float64Array): readonly Contour[] { + let cursor = 1; + return structure.contours.map((contour) => { + const result = new Contour(contour, values, cursor); + cursor += contour.points.length * 2; + return result; + }); + } + + static pointValueOffsets(structure: GlyphStructure): Map { + const offsets = new Map(); + let cursor = 1; + for (const contour of structure.contours) { + for (const point of contour.points) { + offsets.set(point.id, cursor); + cursor += 2; + } + } + return offsets; + } + + get id(): ContourId { + return this.#data.id; + } + + get closed(): boolean { + return this.#data.closed; + } + + get points(): readonly Point[] { + return this.#data.points.map((_, index) => this.#pointAt(index)); + } + + get bounds(): BoundsType | null { + const segments = this.segments(); + if (segments.length === 0) return null; + return Bounds.unionAll(segments.map((segment) => segment.bounds)); + } + + get firstPoint(): Point | null { + return this.points[0] ?? null; + } + + get lastPoint(): Point | null { + const points = this.points; + return points[points.length - 1] ?? null; + } + + get firstOnCurvePoint(): Point | null { + return this.points.find((point) => point.pointType === "onCurve") ?? null; + } + + get lastOnCurvePoint(): Point | null { + const points = this.points; + for (let index = points.length - 1; index >= 0; index--) { + const point = points[index]; + if (point?.pointType === "onCurve") return point; + } + return null; + } + + get isEmpty(): boolean { + return this.#data.points.length === 0; + } + + get hasInteriorPoints(): boolean { + return this.#data.points.length >= 3; + } + + pointAt(index: number, wrap = this.closed): Point | null { + const points = this.points; + if (index >= 0 && index < points.length) return points[index] ?? null; + if (!wrap || points.length === 0) return null; + const wrapped = ((index % points.length) + points.length) % points.length; + return points[wrapped] ?? null; + } + + *withNeighbors(): Generator { + const points = this.points; + for (let index = 0; index < points.length; index++) { + yield { + point: points[index], + prev: points[index - 1] ?? (this.closed ? points[points.length - 1] : null), + next: points[index + 1] ?? (this.closed ? points[0] : null), + }; + } + } + + segments(): Segment[] { + return Segment.parse(this.points, this.closed); + } + + selectionBounds(ids: ReadonlySet): BoundsType | null { + const parts: (BoundsType | null)[] = []; + + for (const segment of this.segments()) { + if (segment.pointIds.every((id) => ids.has(id))) { + parts.push(segment.bounds); + } + } + + parts.push(Bounds.fromPoints(this.points.filter((point) => ids.has(point.id)))); + + return Bounds.unionAll(parts); + } + + canClose(position: Point2D, hitRadius: number): boolean { + const first = this.firstPoint; + if (!first || this.closed) return false; + + return Math.hypot(position.x - first.x, position.y - first.y) <= hitRadius; + } + + #pointAt(index: number): Point { + const point = this.#data.points[index]; + return { + id: point.id, + pointType: point.pointType as PointType, + smooth: point.smooth, + x: this.#values[this.#cursor + index * 2], + y: this.#values[this.#cursor + index * 2 + 1], + }; + } +} diff --git a/packages/font/src/GlyphGeometry.test.ts b/packages/glyph-state/src/GlyphGeometry.test.ts similarity index 87% rename from packages/font/src/GlyphGeometry.test.ts rename to packages/glyph-state/src/GlyphGeometry.test.ts index 44df0efd..7ec9ab1c 100644 --- a/packages/font/src/GlyphGeometry.test.ts +++ b/packages/glyph-state/src/GlyphGeometry.test.ts @@ -1,21 +1,24 @@ import { describe, expect, it } from "vitest"; -import { asContourId } from "@shift/types"; -import type { Glyph, Point, PointId } from "@shift/types"; +import { asContourId, asPointId, type ContourData, type PointData } from "@shift/types"; import { deriveGlyphTightBounds, deriveGlyphXBounds, parseContourSegments, + type SegmentGlyphLike, type SegmentContourLike, } from "./GlyphGeometry"; +type TestPoint = PointData & { x: number; y: number }; +type TestContour = ContourData & { points: TestPoint[] }; + function makePoint( id: string, x: number, y: number, - pointType: Point["pointType"] = "onCurve", -): Point { + pointType: PointData["pointType"] = "onCurve", +) { return { - id: id as PointId, + id: asPointId(id), x, y, pointType, @@ -23,15 +26,8 @@ function makePoint( }; } -function makeGlyph(input: { xAdvance?: number; contours?: Glyph["contours"] }): Glyph { - return { - unicode: 65, - name: "A", - xAdvance: input.xAdvance ?? 500, - contours: input.contours ?? [], - anchors: [], - activeContourId: null, - }; +function makeGlyph(input: { contours?: TestContour[] }): SegmentGlyphLike { + return { contours: input.contours ?? [] }; } describe("parseContourSegments", () => { diff --git a/packages/font/src/GlyphGeometry.ts b/packages/glyph-state/src/GlyphGeometry.ts similarity index 90% rename from packages/font/src/GlyphGeometry.ts rename to packages/glyph-state/src/GlyphGeometry.ts index 7c97a631..74f2934b 100644 --- a/packages/font/src/GlyphGeometry.ts +++ b/packages/glyph-state/src/GlyphGeometry.ts @@ -1,12 +1,12 @@ import { Bounds, Curve, type CurveType } from "@shift/geo"; -import type { Glyph, Point } from "@shift/types"; +import type { PointData } from "@shift/types"; export interface SegmentPointGeometry { readonly x: number; readonly y: number; - readonly pointType: Point["pointType"]; + readonly pointType: PointData["pointType"]; readonly smooth: boolean; - readonly id?: Point["id"]; + readonly id?: PointData["id"]; } export interface SegmentContourLike { @@ -146,7 +146,11 @@ export function segmentToCurve(segment: SegmentGeometry): CurveType { } } -export function deriveGlyphTightBounds(glyph: Glyph): Bounds | null { +export interface SegmentGlyphLike { + readonly contours: readonly SegmentContourLike[]; +} + +export function deriveGlyphTightBounds(glyph: SegmentGlyphLike): Bounds | null { const bounds: Bounds[] = []; for (const contour of glyph.contours ?? []) { @@ -158,7 +162,7 @@ export function deriveGlyphTightBounds(glyph: Glyph): Bounds | null { return Bounds.unionAll(bounds); } -export function deriveGlyphXBounds(glyph: Glyph): { minX: number; maxX: number } | null { +export function deriveGlyphXBounds(glyph: SegmentGlyphLike): { minX: number; maxX: number } | null { const bounds = deriveGlyphTightBounds(glyph); if (!bounds) return null; return { minX: bounds.min.x, maxX: bounds.max.x }; diff --git a/packages/glyph-state/src/GlyphStateGeometry.ts b/packages/glyph-state/src/GlyphStateGeometry.ts new file mode 100644 index 00000000..d9e48463 --- /dev/null +++ b/packages/glyph-state/src/GlyphStateGeometry.ts @@ -0,0 +1,184 @@ +import { Bounds, type Bounds as BoundsType, Vec2, type Point2D } from "@shift/geo"; +import type { AnchorId, ContourId, GlyphState, GlyphStructure, PointId } from "@shift/types"; +import { Anchor } from "./Anchor"; +import { Component } from "./Component"; +import { Contour, type Point } from "./Contour"; + +export interface GlyphSidebearings { + readonly lsb: number | null; + readonly rsb: number | null; +} + +export type GlyphPositionTarget = + | { readonly kind: "point"; readonly id: PointId } + | { readonly kind: "anchor"; readonly id: AnchorId }; + +export type GlyphPosition = + | { readonly kind: "point"; readonly id: PointId; readonly x: number; readonly y: number } + | { + readonly kind: "anchor"; + readonly id: AnchorId; + readonly x: number; + readonly y: number; + }; + +export type GlyphPositions = readonly GlyphPosition[]; + +export type PackedPositionUpdates = [ + pointIds?: BigUint64Array | null, + pointCoords?: Float64Array | null, + anchorIds?: BigUint64Array | null, + anchorCoords?: Float64Array | null, +]; + +export class GlyphStateGeometry { + readonly structure: GlyphStructure; + readonly values: Float64Array; + + readonly #contours: readonly Contour[]; + readonly #anchors: readonly Anchor[]; + readonly #components: readonly Component[]; + + constructor(structure: GlyphStructure, values: Float64Array) { + this.structure = structure; + this.values = values; + this.#contours = Contour.fromStructure(structure, values); + this.#anchors = Anchor.fromStructure(structure, values); + this.#components = Component.fromStructure(structure, values); + } + + static fromState(state: GlyphState): GlyphStateGeometry { + return new GlyphStateGeometry(state.structure, state.values); + } + + get xAdvance(): number { + return this.values[0] ?? 0; + } + + get contours(): readonly Contour[] { + return this.#contours; + } + + get anchors(): readonly Anchor[] { + return this.#anchors; + } + + get components(): readonly Component[] { + return this.#components; + } + + get allPoints(): Point[] { + return this.contours.flatMap((contour) => [...contour.points]); + } + + get bounds(): BoundsType | null { + return Bounds.unionAll(this.contours.map((contour) => contour.bounds)); + } + + get sidebearings(): GlyphSidebearings { + const bounds = Bounds.fromPoints(this.allPoints); + if (!bounds) return { lsb: null, rsb: null }; + return { lsb: bounds.min.x, rsb: this.xAdvance - bounds.max.x }; + } + + point(pointId: PointId): Point | null { + return this.allPoints.find((point) => point.id === pointId) ?? null; + } + + points(pointIds: readonly PointId[]): Point[] { + const ids = new Set(pointIds); + return this.allPoints.filter((point) => ids.has(point.id)); + } + + contour(contourId: ContourId): Contour | null { + return this.contours.find((contour) => contour.id === contourId) ?? null; + } + + anchor(anchorId: AnchorId): Anchor | null { + return this.anchors.find((anchor) => anchor.id === anchorId) ?? null; + } + + positionsFor(targets: readonly GlyphPositionTarget[]): GlyphPosition[] { + const positions: GlyphPosition[] = []; + + for (const target of targets) { + switch (target.kind) { + case "point": { + const point = this.point(target.id); + if (point) positions.push({ kind: "point", id: point.id, x: point.x, y: point.y }); + break; + } + case "anchor": { + const anchor = this.anchor(target.id); + if (anchor) positions.push({ kind: "anchor", id: anchor.id, x: anchor.x, y: anchor.y }); + break; + } + } + } + + return positions; + } + + withPositionUpdates(updates: GlyphPositions): GlyphStateGeometry { + if (updates.length === 0) return this; + + const values = new Float64Array(this.values); + const pointOffsets = Contour.pointValueOffsets(this.structure); + const anchorOffsets = Anchor.valueOffsets(this.structure); + + for (const update of updates) { + switch (update.kind) { + case "point": { + const offset = pointOffsets.get(update.id); + if (offset === undefined) break; + values[offset] = update.x; + values[offset + 1] = update.y; + break; + } + case "anchor": { + const offset = anchorOffsets.get(update.id); + if (offset === undefined) break; + values[offset] = update.x; + values[offset + 1] = update.y; + break; + } + } + } + + return new GlyphStateGeometry(this.structure, values); + } + + movePositions(positions: GlyphPositions, delta: Point2D): GlyphPosition[] { + return positions.map((position) => { + const next = Vec2.add(position, delta); + return { ...position, x: next.x, y: next.y }; + }); + } + + static packPositionUpdates(updates: GlyphPositions): PackedPositionUpdates { + const pointIds: bigint[] = []; + const pointCoords: number[] = []; + const anchorIds: bigint[] = []; + const anchorCoords: number[] = []; + + for (const update of updates) { + switch (update.kind) { + case "point": + pointIds.push(BigInt(update.id)); + pointCoords.push(update.x, update.y); + break; + case "anchor": + anchorIds.push(BigInt(update.id)); + anchorCoords.push(update.x, update.y); + break; + } + } + + return [ + pointIds.length > 0 ? BigUint64Array.from(pointIds) : null, + pointCoords.length > 0 ? Float64Array.from(pointCoords) : null, + anchorIds.length > 0 ? BigUint64Array.from(anchorIds) : null, + anchorCoords.length > 0 ? Float64Array.from(anchorCoords) : null, + ]; + } +} diff --git a/apps/desktop/src/renderer/src/lib/model/Segment.test.ts b/packages/glyph-state/src/Segment.test.ts similarity index 97% rename from apps/desktop/src/renderer/src/lib/model/Segment.test.ts rename to packages/glyph-state/src/Segment.test.ts index 851b7db0..c58af54f 100644 --- a/apps/desktop/src/renderer/src/lib/model/Segment.test.ts +++ b/packages/glyph-state/src/Segment.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; -import { Segment } from "./Segment"; -import type { LineSegment, QuadSegment, CubicSegment } from "@/types/segments"; +import { Segment, type LineSegment, type QuadSegment, type CubicSegment } from "./Segment"; import { asPointId } from "@shift/types"; const pt = (id: string, x: number, y: number, pointType: "onCurve" | "offCurve" = "onCurve") => ({ diff --git a/apps/desktop/src/renderer/src/lib/model/Segment.ts b/packages/glyph-state/src/Segment.ts similarity index 79% rename from apps/desktop/src/renderer/src/lib/model/Segment.ts rename to packages/glyph-state/src/Segment.ts index 7d9ba29c..06ad4bb1 100644 --- a/apps/desktop/src/renderer/src/lib/model/Segment.ts +++ b/packages/glyph-state/src/Segment.ts @@ -1,15 +1,54 @@ import { Curve, Vec2, type Bounds, type CurveType, type Point2D } from "@shift/geo"; -import { parseContourSegments, segmentToCurve } from "@shift/font"; -import type { PointId, Point } from "@shift/types"; -import type { - SegmentPoint, - SegmentType, - LineSegment, - QuadSegment, - CubicSegment, -} from "@/types/segments"; -import type { SegmentId } from "@/types/indicator"; -import { asSegmentId } from "@/types/indicator"; +import type { PointId } from "@shift/types"; +import { parseContourSegments, segmentToCurve } from "./GlyphGeometry"; +import type { Point } from "./Contour"; + +declare const SegmentIdBrand: unique symbol; + +export type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; + +export function asSegmentId(id: string): SegmentId { + return id as SegmentId; +} + +export interface SegmentPoint { + id: PointId; + x: number; + y: number; + pointType: "onCurve" | "offCurve"; + smooth: boolean; +} + +export type LineSegment = { + type: "line"; + points: { + anchor1: SegmentPoint; + anchor2: SegmentPoint; + }; +}; + +export type QuadSegment = { + type: "quad"; + points: { + anchor1: SegmentPoint; + control: SegmentPoint; + anchor2: SegmentPoint; + }; +}; + +export type CubicSegment = { + type: "cubic"; + points: { + anchor1: SegmentPoint; + control1: SegmentPoint; + control2: SegmentPoint; + anchor2: SegmentPoint; + }; +}; + +export type SegmentType = LineSegment | QuadSegment | CubicSegment; export interface SegmentHitResult { segment: Segment; diff --git a/packages/glyph-state/src/index.ts b/packages/glyph-state/src/index.ts new file mode 100644 index 00000000..a2011837 --- /dev/null +++ b/packages/glyph-state/src/index.ts @@ -0,0 +1,35 @@ +export { Anchor } from "./Anchor"; +export { Component, type ComponentTransform, type Matrix } from "./Component"; +export { Contour, type Point, type PointWithNeighbors } from "./Contour"; +export { + GlyphStateGeometry, + type GlyphPosition, + type GlyphPositions, + type GlyphPositionTarget, + type GlyphSidebearings, + type PackedPositionUpdates, +} from "./GlyphStateGeometry"; +export { + Segment, + asSegmentId, + type SegmentHitResult, + type SegmentId, + type SegmentPoint, + type SegmentType, + type LineSegment, + type QuadSegment, + type CubicSegment, +} from "./Segment"; +export { + deriveGlyphTightBounds, + deriveGlyphXBounds, + parseContourSegments, + segmentToCurve, + type SegmentPointGeometry, + type SegmentContourLike, + type SegmentGeometry, + type LineSegmentGeometry, + type QuadSegmentGeometry, + type CubicSegmentGeometry, + type SegmentGlyphLike, +} from "./GlyphGeometry"; diff --git a/packages/font/tsconfig.json b/packages/glyph-state/tsconfig.json similarity index 100% rename from packages/font/tsconfig.json rename to packages/glyph-state/tsconfig.json diff --git a/packages/font/vitest.config.ts b/packages/glyph-state/vitest.config.ts similarity index 100% rename from packages/font/vitest.config.ts rename to packages/glyph-state/vitest.config.ts diff --git a/packages/rules/docs/DOCS.md b/packages/rules/docs/DOCS.md index 396a2735..187bb955 100644 --- a/packages/rules/docs/DOCS.md +++ b/packages/rules/docs/DOCS.md @@ -9,7 +9,7 @@ Point editing rules engine that enforces geometric constraints (tangency, collin - **Architecture Invariant:** `constrainDrag` operates on a **base glyph snapshot** (positions before the drag started). The drag delta (`mousePosition`) is applied internally. Callers must not pre-apply the delta to the glyph. - **Architecture Invariant:** Rule moves override selected-point moves in the final `DragPatch`. If a rule computes a position for a point that is also selected, the rule position wins. - **Architecture Invariant:** Precedence score is `patternLength * 1000 + priority`. Window size dominates by default (5-char patterns outscore 3-char patterns); `priority` breaks ties within the same length. -- **Architecture Invariant:** `prepareConstrainDrag` short-circuits rule resolution entirely when `selectionNeedsRuleResolution` returns false (all selected points are non-smooth corners with no adjacent handles or smooth points). In this case `allowsUniformTranslationCommit` is true and no rules are evaluated. +- **Architecture Invariant:** `prepareConstrainedDrag` short-circuits rule resolution entirely when `selectionNeedsRuleResolution` returns false (all selected points are non-smooth corners with no adjacent handles or smooth points). In this case `allowsUniformTranslationCommit` is true and no rules are evaluated. ## Codemap @@ -19,7 +19,7 @@ rules/src/ parser.ts -- expandPattern: template string -> concrete pattern strings rules.ts -- RULE_SPECS definitions, buildRuleTable, getRuleTable singleton matcher.ts -- pickRule, diagnoseSelectionPatterns: pattern building + table lookup - actions.ts -- constrainDrag, prepareConstrainDrag, applyRule: execute matched rules + actions.ts -- constrainDrag, prepareConstrainedDrag, applyRule: execute matched rules constraints.ts -- maintainTangency, maintainCollinearity: vector math primitives index.ts -- public re-exports ``` @@ -33,7 +33,7 @@ rules/src/ - `RuleMatch` -- expanded entry stored in the rule table: resolved `Rule`, affected specs, precedence score, source template - `MatchedRule` -- runtime match result: `pointId`, `ruleId`, `pattern`, and `affected` (role-keyed map of resolved `PointId` values) - `DragPatch` -- output of `constrainDrag`: absolute `pointUpdates` and the list of `matched` rules -- `PreparedConstrainDrag` -- pre-computed state from `prepareConstrainDrag` (point index, matched rules, selected points) reusable across frames with different `mousePosition` values +- `PreparedConstrainDrag` -- pre-computed state from `prepareConstrainedDrag` (point index, matched rules, selected points) reusable across frames with different `mousePosition` values - `SelectionRuleDiagnostics` -- per-point probe results for debugging pattern matches in dev tools ## How it works @@ -44,7 +44,7 @@ rules/src/ ### Pattern matching (drag start) -`prepareConstrainDrag` builds a `PointIndex` from the base glyph snapshot, then iterates each selected point. For each point, `pickRuleAtIndex` tries window sizes 5 and 3 (in that order). For each window size, `buildPattern` reads the point neighborhood from the contour using `Contours.at` for wrap-around on closed contours, maps each point to a token (`N`/`C`/`S`/`H`/`@`), and concatenates them into a concrete pattern string. The string is looked up in the rule table. The highest-precedence match wins. `computeAffectedPoints` resolves each `AffectedPointSpec` offset into a concrete `PointId`. +`prepareConstrainedDrag` builds a `PointIndex` from the base glyph snapshot, then iterates each selected point. For each point, `pickRuleAtIndex` tries window sizes 5 and 3 (in that order). For each window size, `buildPattern` reads the point neighborhood from the contour using `Contours.at` for wrap-around on closed contours, maps each point to a token (`N`/`C`/`S`/`H`/`@`), and concatenates them into a concrete pattern string. The string is looked up in the rule table. The highest-precedence match wins. `computeAffectedPoints` resolves each `AffectedPointSpec` offset into a concrete `PointId`. ### Rule application (every frame) diff --git a/packages/rules/package.json b/packages/rules/package.json index ac7adbb5..ea8d0dfc 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -18,7 +18,6 @@ "test:watch": "vitest" }, "dependencies": { - "@shift/font": "workspace:*", "@shift/geo": "workspace:*", "@shift/types": "workspace:*" }, diff --git a/packages/rules/src/actions.ts b/packages/rules/src/actions.ts index d5b825fd..05cb0c99 100644 --- a/packages/rules/src/actions.ts +++ b/packages/rules/src/actions.ts @@ -2,9 +2,10 @@ * Rule Actions - Apply matched rules to compute point positions */ -import { Vec2 } from "@shift/geo"; -import type { ContourSnapshot, Point, PointId, GlyphSnapshot, Point2D } from "@shift/types"; +import { Vec2, type Point2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; import type { + ConstrainDragGlyph, DragPatch, MatchedRule, MatchedRuleById, @@ -15,9 +16,12 @@ import type { import { pickRuleAtIndex } from "./matcher"; import { maintainCollinearity, maintainTangency } from "./constraints"; +type ConstrainDragContour = ConstrainDragGlyph["contours"][number]; +type ConstrainDragPoint = ConstrainDragContour["points"][number]; + type IndexedPoint = { - point: Point; - contour: ContourSnapshot; + point: ConstrainDragPoint; + contour: ConstrainDragContour; index: number; }; @@ -26,16 +30,16 @@ type PointIndex = Map; export interface PreparedConstrainDrag { selectedIds: ReadonlySet; pointIndex: PointIndex; - selectedPoints: readonly Point[]; + selectedPoints: readonly ConstrainDragPoint[]; matchedRules: readonly MatchedRule[]; allowsUniformTranslationCommit: boolean; } function getPointAtContourOffset( - contour: ContourSnapshot, + contour: ConstrainDragContour, centerIndex: number, offset: number, -): Point | undefined { +): ConstrainDragPoint | undefined { const nextIndex = centerIndex + offset; if (contour.closed) { const total = contour.points.length; @@ -46,7 +50,7 @@ function getPointAtContourOffset( return contour.points[nextIndex]; } -function buildPointIndex(glyph: GlyphSnapshot): PointIndex { +function buildPointIndex(glyph: ConstrainDragGlyph): PointIndex { const index = new Map(); for (const contour of glyph.contours) { @@ -64,7 +68,10 @@ function buildPointIndex(glyph: GlyphSnapshot): PointIndex { return index; } -function findPointById(pointIndex: PointIndex, pointId: PointId | undefined): Point | null { +function findPointById( + pointIndex: PointIndex, + pointId: PointId | undefined, +): ConstrainDragPoint | null { if (!pointId) return null; const found = pointIndex.get(pointId); if (!found) return null; @@ -75,7 +82,7 @@ function findAffectedPointByRole, role: Role, -): Point | null { +): ConstrainDragPoint | null { return findPointById(pointIndex, rule.affected[role]); } @@ -86,13 +93,17 @@ function findAffectedPointsByRole< pointIndex: PointIndex, rule: MatchedRuleById, ...roles: Roles -): { [K in keyof Roles]: Point | null } { +): { [K in keyof Roles]: ConstrainDragPoint | null } { return roles.map((role) => findAffectedPointByRole(pointIndex, rule, role)) as { - [K in keyof Roles]: Point | null; + [K in keyof Roles]: ConstrainDragPoint | null; }; } -function pushTranslatedMove(moves: PointMove[], point: Point | null, mousePos: Point2D): void { +function pushTranslatedMove( + moves: PointMove[], + point: ConstrainDragPoint | null, + mousePos: Point2D, +): void { if (!point) return; moves.push({ id: point.id, @@ -128,12 +139,12 @@ function selectionNeedsRuleResolution( return false; } -export function prepareConstrainDrag( - glyph: GlyphSnapshot, +export function prepareConstrainedDrag( + glyph: ConstrainDragGlyph, selectedIds: ReadonlySet, ): PreparedConstrainDrag { const pointIndex = buildPointIndex(glyph); - const selectedPoints: Point[] = []; + const selectedPoints: ConstrainDragPoint[] = []; const matchedRules: MatchedRule[] = []; const needsRuleResolution = selectionNeedsRuleResolution(pointIndex, selectedIds); @@ -317,7 +328,7 @@ function applyRule(pointIndex: PointIndex, rule: MatchedRule, mousePos: Point2D) * `glyph` is the **base** glyph (unchanged). The delta is applied internally. */ export interface ConstrainDragInput { - glyph: GlyphSnapshot; + glyph: ConstrainDragGlyph; selectedIds: ReadonlySet; mousePosition: Point2D; } @@ -331,7 +342,7 @@ export function constrainDrag( options?: ConstrainDragOptions, ): DragPatch { const { glyph, selectedIds, mousePosition } = input; - return constrainPreparedDrag(prepareConstrainDrag(glyph, selectedIds), mousePosition, options); + return constrainPreparedDrag(prepareConstrainedDrag(glyph, selectedIds), mousePosition, options); } export function constrainPreparedDrag( diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 59dab51b..c42d1b42 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -21,6 +21,7 @@ // Types export type { AffectedPointRole, + ConstrainDragGlyph, RuleId, RuleAffectedRole, RuleAffectedRolesById, @@ -38,5 +39,5 @@ export type { export { pickRule, diagnoseSelectionPatterns } from "./matcher"; // Rule application -export { constrainDrag, constrainPreparedDrag, prepareConstrainDrag } from "./actions"; +export { constrainDrag, constrainPreparedDrag, prepareConstrainedDrag } from "./actions"; export type { PreparedConstrainDrag } from "./actions"; diff --git a/packages/rules/src/matcher.ts b/packages/rules/src/matcher.ts index ee873077..6b8a6bec 100644 --- a/packages/rules/src/matcher.ts +++ b/packages/rules/src/matcher.ts @@ -2,9 +2,9 @@ * Pattern Matcher - Matches point patterns against rules */ -import { Contours, Glyphs } from "@shift/font"; -import type { PointId, Point, Contour, Glyph } from "@shift/types"; +import type { PointId } from "@shift/types"; import type { + ConstrainDragGlyph, MatchedRule, MatchedRuleAffected, MatchedRuleById, @@ -19,11 +19,14 @@ import type { AffectedPointSpec, RuleMatch } from "./rules"; const WINDOW_SIZES = [5, 3] as const; +type ConstrainDragContour = ConstrainDragGlyph["contours"][number]; +type ConstrainDragPoint = ConstrainDragContour["points"][number]; + /** * Get point token for pattern matching */ function getPointToken( - point: Point | undefined, + point: ConstrainDragPoint | undefined, selectedIds: ReadonlySet, isCentral: boolean, ): string { @@ -45,14 +48,19 @@ function getPointToken( return point.smooth ? TOKEN_SMOOTH : TOKEN_CORNER; } -type ContourMatchInput = Contour; +type ContourMatchInput = ConstrainDragContour; function getPointAtOffset( contour: ContourMatchInput, centerIndex: number, offset: number, -): Point | undefined { - return Contours.at(contour, centerIndex + offset, contour.closed) ?? undefined; +): ConstrainDragPoint | undefined { + const index = centerIndex + offset; + if (index >= 0 && index < contour.points.length) return contour.points[index]; + if (!contour.closed || contour.points.length === 0) return undefined; + + const wrapped = ((index % contour.points.length) + contour.points.length) % contour.points.length; + return contour.points[wrapped]; } /** @@ -218,7 +226,7 @@ export function pickRule( pointId: PointId, selectedIds: ReadonlySet, ): MatchedRule | null { - const pointIndex = Contours.findPointIndex(contour, pointId); + const pointIndex = contour.points.findIndex((point) => point.id === pointId); if (pointIndex === -1) { return null; } @@ -239,13 +247,13 @@ export function pickRuleAtIndex( * Build per-point rule diagnostics for the current selection. */ export function diagnoseSelectionPatterns( - glyph: Glyph, + glyph: ConstrainDragGlyph, selectedIds: ReadonlySet, ): SelectionRuleDiagnostics { const diagnostics: PointRuleDiagnostics[] = []; for (const pointId of selectedIds) { - const found = Glyphs.findPoint(glyph, pointId); + const found = findPoint(glyph, pointId); if (!found) { diagnostics.push({ pointId, @@ -274,3 +282,15 @@ export function diagnoseSelectionPatterns( points: diagnostics, }; } + +function findPoint( + glyph: ConstrainDragGlyph, + pointId: PointId, +): { contour: ConstrainDragContour; index: number } | null { + for (const contour of glyph.contours) { + const index = contour.points.findIndex((point) => point.id === pointId); + if (index !== -1) return { contour, index }; + } + + return null; +} diff --git a/packages/rules/src/rules.test.ts b/packages/rules/src/rules.test.ts index bd3ab001..3247aede 100644 --- a/packages/rules/src/rules.test.ts +++ b/packages/rules/src/rules.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest"; import { expandPattern } from "./parser"; import { buildRuleTable, buildRuleTableFromSpecs } from "./rules"; import { diagnoseSelectionPatterns, pickRule } from "./matcher"; -import { constrainDrag, prepareConstrainDrag } from "./actions"; -import type { ContourSnapshot, GlyphSnapshot, PointSnapshot } from "@shift/types"; +import { constrainDrag, prepareConstrainedDrag } from "./actions"; import type { PointId, ContourId } from "@shift/types"; -import type { PointMove } from "./types"; +import type { ConstrainDragGlyph, PointMove } from "./types"; + +type ConstrainDragContour = ConstrainDragGlyph["contours"][number]; +type ConstrainDragPoint = ConstrainDragContour["points"][number]; // Helper to create test points function createPoint( @@ -14,7 +16,7 @@ function createPoint( y: number, type: "onCurve" | "offCurve", smooth: boolean = false, -): PointSnapshot { +): ConstrainDragPoint { return { id: id as PointId, x, @@ -27,9 +29,9 @@ function createPoint( // Helper to create test contour function createContour( id: string, - points: PointSnapshot[], + points: ConstrainDragPoint[], closed: boolean = false, -): ContourSnapshot { +): ConstrainDragContour { return { id: id as ContourId, points, @@ -38,19 +40,13 @@ function createContour( } // Helper to create test glyph -function createGlyph(contours: ContourSnapshot[]): GlyphSnapshot { +function createGlyph(contours: ConstrainDragContour[]): ConstrainDragGlyph { return { - unicode: 65, // 'A' - name: "test", - xAdvance: 500, contours, - anchors: [], - compositeContours: [], - activeContourId: null, }; } -function applyPointMovesToGlyph(glyph: GlyphSnapshot, moves: PointMove[]): GlyphSnapshot { +function applyPointMovesToGlyph(glyph: ConstrainDragGlyph, moves: PointMove[]): ConstrainDragGlyph { const moveMap = new Map(); for (const move of moves) { moveMap.set(move.id, move); @@ -70,7 +66,7 @@ function applyPointMovesToGlyph(glyph: GlyphSnapshot, moves: PointMove[]): Glyph } function runConstrainDrag( - glyph: GlyphSnapshot, + glyph: ConstrainDragGlyph, selectedIds: ReadonlySet, dx: number, dy: number, @@ -588,7 +584,7 @@ describe("Rule Application", () => { ]); const selected = new Set(["corner1" as PointId, "corner2" as PointId]); - const prepared = prepareConstrainDrag(glyph, selected); + const prepared = prepareConstrainedDrag(glyph, selected); expect(prepared.allowsUniformTranslationCommit).toBe(true); }); @@ -621,7 +617,7 @@ describe("Rule Application", () => { const glyph = createTestGlyph(); const selected = new Set(["smooth" as PointId]); - const prepared = prepareConstrainDrag(glyph, selected); + const prepared = prepareConstrainedDrag(glyph, selected); expect(prepared.allowsUniformTranslationCommit).toBe(false); }); diff --git a/packages/rules/src/types.ts b/packages/rules/src/types.ts index db457ba8..684eaebb 100644 --- a/packages/rules/src/types.ts +++ b/packages/rules/src/types.ts @@ -1,4 +1,13 @@ -import type { PointId } from "@shift/types"; +import type { Point2D } from "@shift/geo"; +import type { ContourId, PointData, PointId } from "@shift/types"; + +export interface ConstrainDragGlyph { + readonly contours: readonly { + readonly id: ContourId; + readonly closed: boolean; + readonly points: readonly (PointData & Point2D)[]; + }[]; +} /** * Rule identifiers matching the Rust implementation diff --git a/packages/types/docs/DOCS.md b/packages/types/docs/DOCS.md index 80b29491..0c5a550d 100644 --- a/packages/types/docs/DOCS.md +++ b/packages/types/docs/DOCS.md @@ -1,6 +1,6 @@ # @shift/types -Shared DTO and primitive TypeScript types for Shift. This package owns branded IDs, math primitives, and bridge DTOs generated from `shift-bridge`. +Shared DTO TypeScript types for Shift. This package owns branded IDs and bridge DTOs generated from `shift-bridge`. ## Architecture Invariants @@ -14,8 +14,7 @@ Shared DTO and primitive TypeScript types for Shift. This package owns branded I ``` packages/types/src/ - index.ts -- root barrel: IDs, math, bridge DTOs - math.ts -- Point2D, Rect2D, TransformMatrix + index.ts -- root barrel: IDs and bridge DTOs ids.ts -- branded IDs + cast helpers bridge/ index.ts -- stable bridge DTO barrel diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index 362aad8c..82595980 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -16,199 +16,204 @@ export type GlyphName = string; export type Unicode = number; export interface BridgeApi { - loadFont(path: string): void; - saveFont(path: string): Promise; - getMetadata(): FontMetadata; - getMetrics(): FontMetrics; - getGlyphCount(): number; - getGlyphs(): Array; - getGlyphState(glyphRef: GlyphHandle): GlyphState | null; - isVariable(): boolean; - getAxes(): Array; - getSources(): Array; - startEditSession(glyphRef: GlyphHandle): void; - getLiveVersion(): number; - getPersistedVersion(): number; - isDirty(): boolean; - endEditSession(): void; - hasEditSession(): boolean; - getEditingUnicode(): Unicode | null; - getEditingGlyphName(): GlyphName | null; - setXAdvance(width: number): GlyphValueChange; - translateLayer(dx: number, dy: number): GlyphValueChange; - addPoint( - contourId: ContourId, - x: number, - y: number, - pointType: PointType, - smooth: boolean, - ): GlyphStructureChange; - insertPointBefore( - beforePointId: PointId, - x: number, - y: number, - pointType: PointType, - smooth: boolean, - ): GlyphStructureChange; - addContour(): GlyphStructureChange; - openContour(contourId: ContourId): GlyphStructureChange; - closeContour(contourId: ContourId): GlyphStructureChange; - reverseContour(contourId: ContourId): GlyphStructureChange; - applyBooleanOp( - contourIdA: ContourId, - contourIdB: ContourId, - operation: string, - ): GlyphStructureChange; - removePoints(pointIds: Array): GlyphStructureChange; - toggleSmooth(pointId: PointId): GlyphStructureChange; + loadFont(path: string): void + saveFont(path: string): Promise + getMetadata(): FontMetadata + getMetrics(): FontMetrics + getGlyphCount(): number + getGlyphs(): Array + getGlyphState(glyphHandle: GlyphHandle, sourceId: SourceId): GlyphState | null + getGlyphVariationReport(glyphRef: GlyphHandle): GlyphVariationReport | null + getVariationReports(): Array + isVariable(): boolean + getAxes(): Array + getSources(): Array + startEditSession(glyphHandle: GlyphHandle, sourceId: SourceId): void + getPersistedVersion(): number + isDirty(): boolean + endEditSession(): void + hasEditSession(): boolean + getEditingUnicode(): Unicode | null + getEditingGlyphName(): GlyphName | null + getEditingSourceId(): SourceId | null + setXAdvance(width: number): GlyphValueChange + translateLayer(dx: number, dy: number): GlyphValueChange + addPoint(contourId: ContourId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange + insertPointBefore(beforePointId: PointId, x: number, y: number, pointType: PointType, smooth: boolean): GlyphStructureChange + addContour(): GlyphStructureChange + openContour(contourId: ContourId): GlyphStructureChange + closeContour(contourId: ContourId): GlyphStructureChange + reverseContour(contourId: ContourId): GlyphStructureChange + applyBooleanOp(contourIdA: ContourId, contourIdB: ContourId, operation: string): GlyphStructureChange + removePoints(pointIds: Array): GlyphStructureChange + toggleSmooth(pointId: PointId): GlyphStructureChange /** * Bulk position sync. IDs use BigUint64Array to avoid lossy float packing. * Coords are interleaved [x0, y0, x1, y1, ...]. */ - setPositions( - pointIds?: BigUint64Array | undefined | null, - pointCoords?: Float64Array | undefined | null, - anchorIds?: BigUint64Array | undefined | null, - anchorCoords?: Float64Array | undefined | null, - ): GlyphValueChange; - restoreState(structure: GlyphStructure, values: Float64Array): GlyphStructureChange; + setPositions(pointIds?: BigUint64Array | undefined | null, pointCoords?: Float64Array | undefined | null, anchorIds?: BigUint64Array | undefined | null, anchorCoords?: Float64Array | undefined | null): GlyphValueChange + restoreState(structure: GlyphStructure, values: Float64Array): GlyphStructureChange } export interface GlyphHandle { - name: GlyphName; - unicode?: Unicode; + name: GlyphName + unicode?: Unicode +} + +export interface GlyphVariationDiagnostic { + glyphName: GlyphName + code: string + severity: string + source?: GlyphVariationDiagnosticSource + message: string +} + +export interface GlyphVariationDiagnosticSource { + id: SourceId + index: number + name: string +} + +export interface GlyphVariationReport { + glyphName: GlyphName + status: string + variationDataAvailable: boolean + masterCount: number + compatibleMasterCount: number + skippedMasterCount: number + diagnostics: Array } export interface AnchorData { - id: AnchorId; - name?: string; + id: AnchorId + name?: string } export interface Axis { - tag: string; - name: string; - minimum: number; - default: number; - maximum: number; - hidden: boolean; + tag: string + name: string + minimum: number + default: number + maximum: number + hidden: boolean } export interface AxisTent { - axisTag: string; - lower: number; - peak: number; - upper: number; + axisTag: string + lower: number + peak: number + upper: number } export interface ComponentData { - id: ComponentId; - baseGlyphName: GlyphName; + id: ComponentId + baseGlyphName: GlyphName } export interface ContourData { - id: ContourId; - points: Array; - closed: boolean; + id: ContourId + points: Array + closed: boolean } export interface FontMetadata { - familyName?: string; - styleName?: string; - versionMajor?: number; - versionMinor?: number; - copyright?: string; - trademark?: string; - designer?: string; - designerUrl?: string; - manufacturer?: string; - manufacturerUrl?: string; - license?: string; - licenseUrl?: string; - description?: string; - note?: string; + familyName?: string + styleName?: string + versionMajor?: number + versionMinor?: number + copyright?: string + trademark?: string + designer?: string + designerUrl?: string + manufacturer?: string + manufacturerUrl?: string + license?: string + licenseUrl?: string + description?: string + note?: string } export interface FontMetrics { - unitsPerEm: number; - ascender: number; - descender: number; - capHeight?: number; - xHeight?: number; - lineGap?: number; - italicAngle?: number; - underlinePosition?: number; - underlineThickness?: number; + unitsPerEm: number + ascender: number + descender: number + capHeight?: number + xHeight?: number + lineGap?: number + italicAngle?: number + underlinePosition?: number + underlineThickness?: number } export interface GlyphChangedEntities { - pointIds: Array; - contourIds: Array; - anchorIds: Array; - guidelineIds: Array; - componentIds: Array; + pointIds: Array + contourIds: Array + anchorIds: Array + guidelineIds: Array + componentIds: Array } export interface GlyphMaster { - sourceId: SourceId; - sourceName: string; - isDefaultSource: boolean; - location: Location; - structure: GlyphStructure; - values: Float64Array; + sourceId: SourceId + sourceName: string + isDefaultSource: boolean + location: Location + structure: GlyphStructure + values: Float64Array } export interface GlyphRecord { - name: GlyphName; - unicodes: Array; - componentBaseGlyphNames: Array; + name: GlyphName + unicodes: Array + componentBaseGlyphNames: Array } export interface GlyphState { - structure: GlyphStructure; + structure: GlyphStructure /** Numeric glyph state ordered to match `GlyphStructure`. */ - values: Float64Array; - variationData?: GlyphVariationData; + values: Float64Array + variationData?: GlyphVariationData } export interface GlyphStructure { - contours: Array; - anchors: Array; - components: Array; + contours: Array + anchors: Array + components: Array } export interface GlyphStructureChange { - structure: GlyphStructure; - values: Float64Array; - changed: GlyphChangedEntities; + structure: GlyphStructure + values: Float64Array + changed: GlyphChangedEntities } export interface GlyphValueChange { - values: Float64Array; - changed: GlyphChangedEntities; + values: Float64Array + changed: GlyphChangedEntities } export interface GlyphVariationData { /** One entry per region. Inner = tents on the axes the region depends on. */ - regions: Array>; + regions: Array> /** Deltas are flattened in `GlyphState::values` order. */ - deltas: Array; + deltas: Array } export interface Location { - values: Record; + values: Record } export interface PointData { - id: PointId; - pointType: PointType; - smooth: boolean; + id: PointId + pointType: PointType + smooth: boolean } export type PointType = "onCurve" | "offCurve"; export interface Source { - id: SourceId; - name: string; - location: Location; - layerId: LayerId; - filename?: string; + id: SourceId + name: string + location: Location + layerId: LayerId + filename?: string } diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index 355d4e5e..31571a25 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -17,6 +17,9 @@ export type { GlyphStructureChange, GlyphValueChange, GlyphVariationData, + GlyphVariationDiagnostic, + GlyphVariationDiagnosticSource, + GlyphVariationReport, Location, PointData, PointType, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 76aef6de..c804b3ec 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -48,6 +48,9 @@ export type { GlyphStructureChange, GlyphValueChange, GlyphVariationData, + GlyphVariationDiagnostic, + GlyphVariationDiagnosticSource, + GlyphVariationReport, Location, PointData, PointType, diff --git a/packages/validation/docs/DOCS.md b/packages/validation/docs/DOCS.md index 48c21be2..ee422364 100644 --- a/packages/validation/docs/DOCS.md +++ b/packages/validation/docs/DOCS.md @@ -7,7 +7,7 @@ Point sequence validation and persistence schema checking for the Shift font edi - **Architecture Invariant:** Every validator returns a `ValidationResult` discriminated union -- callers branch on `valid` and access either `.value` (parsed payload) or `.errors` (structured `ValidationError[]`). Never throw from validators. - **Architecture Invariant:** Point sequences must start and end with `onCurve` points. At most 2 consecutive `offCurve` points are allowed (cubic bezier). 3+ consecutive off-curve points are always invalid. - **Architecture Invariant:** `Validate` methods come in pairs: a `ValidationResult`-returning variant for detailed errors (e.g. `canFormSegments`) and a boolean shortcut for hot paths (e.g. `canFormValidSegments`). The boolean variants must enforce identical rules without allocating error objects. -- **Architecture Invariant:** `ValidateSnapshot` type guards narrow `unknown` to concrete snapshot types (`PointSnapshot`, `ContourSnapshot`, `GlyphSnapshot`). These are the sole defense before data crosses the NAPI boundary to Rust -- bypassing them can crash the native engine. +- **Architecture Invariant:** Clipboard and persistence validation check serialized boundary payloads only. Editor/runtime glyph state validation belongs with the source-aware glyph model, not snapshot-era DTOs. - **Architecture Invariant:** Persistence schemas use Zod and are the single source of truth for on-disk format shape. The inferred types (e.g. `PersistedRoot`) are derived from schemas, never hand-written separately. ## Codemap @@ -16,9 +16,8 @@ Point sequence validation and persistence schema checking for the Shift font edi validation/src/ types.ts -- ValidationResult, ValidationError, ValidationErrorCode, PointLike Validate.ts -- point sequence rules: ordering, segment formation, anchor checks - ValidateSnapshot.ts -- runtime type guards for GlyphSnapshot, ContourSnapshot, etc. ValidateClipboard.ts -- clipboard payload shape checks (contours, points, metadata) - persistence.ts -- Zod schemas for persisted document state, preferences, text runs + persistence.ts -- Zod schemas for persisted document state and text runs index.ts -- public barrel export ``` @@ -26,14 +25,12 @@ validation/src/ - `ValidationResult` -- discriminated union: `{ valid: true; value: T }` or `{ valid: false; errors: ValidationError[] }`. Default `T` is `void` for check-only validators. - `ValidationError` -- structured failure with machine-readable `code` (`ValidationErrorCode`), human-readable `message`, and optional `context` record. -- `ValidationErrorCode` -- union of all failure codes: `EMPTY_SEQUENCE`, `MUST_START_WITH_ON_CURVE`, `MUST_END_WITH_ON_CURVE`, `TOO_MANY_CONSECUTIVE_OFF_CURVE`, `ORPHAN_OFF_CURVE`, `INCOMPLETE_SEGMENT`, `INVALID_SNAPSHOT_STRUCTURE`, `INVALID_CONTOUR_STRUCTURE`, `INVALID_POINT_STRUCTURE`, `INVALID_POINT_TYPE`, `INVALID_CLIPBOARD_CONTENT`. +- `ValidationErrorCode` -- union of all failure codes: `EMPTY_SEQUENCE`, `MUST_START_WITH_ON_CURVE`, `MUST_END_WITH_ON_CURVE`, `TOO_MANY_CONSECUTIVE_OFF_CURVE`, `ORPHAN_OFF_CURVE`, `INCOMPLETE_SEGMENT`, `INVALID_CLIPBOARD_CONTENT`. - `PointLike` -- minimal `{ pointType: PointType }` interface accepted by all point-sequence validators. Full `Point` objects, snapshots, and test stubs all satisfy it. - `Validate` -- namespace object with point predicates (`isOnCurve`, `isOffCurve`), pattern matchers (`matchesLinePattern`, `matchesQuadPattern`, `matchesCubicPattern`), sequence validators (`sequence`, `canFormSegments`), boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`), and result constructors (`ok`, `fail`, `error`). -- `ValidateSnapshot` -- namespace object with type guards for `PointSnapshot`, `ContourSnapshot`, `AnchorSnapshot`, `RenderPointSnapshot`, `RenderContourSnapshot`, `GlyphSnapshot`. Also provides `glyphSnapshot` which returns `ValidationResult` with detailed field-level errors. - `ValidateClipboard` -- namespace object with `isClipboardContent` (validates contour array shape) and `isClipboardPayload` (validates full `shift/glyph-data` envelope with format, version, metadata, content). - `PersistedRootSchema` -- top-level Zod schema for the entire persisted state file (registry, app modules, documents). - `PersistedDocumentSchema` -- Zod schema for a single document's persisted state (docId, updatedAt, modules map). -- `SnapPreferencesSchema` / `UserPreferencesSchema` -- Zod schemas for user snap/preference settings. - `TextRunModuleSchema` -- Zod schema for the text-run persistence module payload. ## How it works @@ -46,12 +43,6 @@ validation/src/ Boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`) implement the same logic without allocating `ValidationError` objects, for use in render loops and per-frame checks. -### Snapshot validation - -`ValidateSnapshot` provides runtime type narrowing for glyph data coming from undo/redo snapshots, clipboard, or persistence. Each `is*` method performs exhaustive field-by-field checks (type, finiteness of numbers, nested arrays). `glyphSnapshot` returns a `ValidationResult` with the first failing field identified in the error context. - -`NativeBridge` calls `ValidateSnapshot.isGlyphSnapshot` before sending snapshots to Rust -- this is the last line of defense against malformed data crashing the native engine. - ### Clipboard validation `ValidateClipboard.isClipboardContent` validates the contour/point structure of clipboard data. `isClipboardPayload` checks the full envelope (format string `"shift/glyph-data"`, version number, metadata with timestamp). Used by `Clipboard` when parsing pasted content. @@ -68,12 +59,6 @@ Zod schemas in `persistence.ts` validate the shape of data read from disk. `Pers 2. Use it in a validator via `Validate.error("YOUR_CODE", "message")` 3. Add test cases covering the new failure path -### Add a new snapshot type guard - -1. Define the shape type in `@shift/types` -2. Add an `is*` method on `ValidateSnapshot` that checks each field -3. If detailed errors are needed, add a `ValidationResult`-returning variant following the pattern of `glyphSnapshot` - ### Add a new persistence schema 1. Define the Zod schema in `persistence.ts` @@ -82,10 +67,9 @@ Zod schemas in `persistence.ts` validate the shape of data read from disk. `Pers ## Gotchas -- `ValidateSnapshot` type guards check `Number.isFinite` on coordinates -- `NaN` and `Infinity` both fail. This is intentional to prevent rendering artifacts and Rust panics. - `Validate.sequence` accepts a single `onCurve` point as valid, but `Validate.canFormSegments` requires at least 2 points. Use the right one depending on whether you need drawable segments or just a well-formed sequence. - `ValidateClipboard.isClipboardPayload` hardcodes the format string `"shift/glyph-data"`. If the clipboard format changes, this must be updated in sync. -- The `PointLike` interface only requires `pointType` -- validators do not check coordinates or IDs. Use `ValidateSnapshot.isPointSnapshot` when full structural validation is needed. +- The `PointLike` interface only requires `pointType` -- validators do not check coordinates or IDs. Use clipboard or persistence validators at serialization boundaries when full structural validation is needed. ## Verification @@ -100,9 +84,7 @@ cd packages/validation && npx tsc --noEmit ## Related - `Clipboard` -- uses `Validate.hasValidAnchor` for copy eligibility and `ValidateClipboard.isClipboardContent` for paste parsing -- `NativeBridge` -- calls `ValidateSnapshot.isGlyphSnapshot` before Rust snapshot restore - `Segments` / `Segment` -- uses `Validate.isOnCurve` / `Validate.isOffCurve` for segment decomposition -- `SnapManager` -- uses `Validate.isOnCurve` / `Validate.isOffCurve` for snap target classification -- `Editor` -- parses `SnapPreferencesSchema` and `TextRunModuleSchema` from persisted state +- `Editor` -- parses `TextRunModuleSchema` from persisted state - `persistence/kernel` -- parses `PersistedRootSchema` on app startup - `PointType` from `@shift/types` -- the underlying union (`"onCurve" | "offCurve"`) that `PointLike` wraps diff --git a/packages/validation/src/ValidateClipboard.ts b/packages/validation/src/ValidateClipboard.ts index 22f5af39..7082525d 100644 --- a/packages/validation/src/ValidateClipboard.ts +++ b/packages/validation/src/ValidateClipboard.ts @@ -1,4 +1,6 @@ -import { ValidateSnapshot } from "./ValidateSnapshot"; +import type { PointType } from "@shift/types"; + +const VALID_POINT_TYPES: ReadonlySet = new Set(["onCurve", "offCurve"]); function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -8,7 +10,7 @@ function isValidPoint(v: unknown): boolean { if (!isRecord(v)) return false; if (typeof v.x !== "number" || !Number.isFinite(v.x)) return false; if (typeof v.y !== "number" || !Number.isFinite(v.y)) return false; - if (!ValidateSnapshot.isValidPointType(v.pointType)) return false; + if (typeof v.pointType !== "string" || !VALID_POINT_TYPES.has(v.pointType)) return false; if (typeof v.smooth !== "boolean") return false; return true; } diff --git a/packages/validation/src/ValidateSnapshot.test.ts b/packages/validation/src/ValidateSnapshot.test.ts deleted file mode 100644 index e0857d6c..00000000 --- a/packages/validation/src/ValidateSnapshot.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { ValidateSnapshot } from "./ValidateSnapshot"; - -const validPoint = () => ({ - id: "p1", - x: 100, - y: 200, - pointType: "onCurve" as const, - smooth: false, -}); - -const validContour = () => ({ - id: "c1", - points: [validPoint()], - closed: true, -}); - -const validAnchor = () => ({ - id: "a1", - name: "top", - x: 150, - y: 650, -}); - -const validRenderPoint = () => ({ - x: 100, - y: 720, - pointType: "onCurve" as const, - smooth: false, -}); - -const validRenderContour = () => ({ - points: [validRenderPoint()], - closed: true, -}); - -const validGlyph = () => ({ - unicode: 65, - name: "A", - xAdvance: 600, - contours: [validContour()], - anchors: [validAnchor()], - compositeContours: [validRenderContour()], - activeContourId: null, -}); - -describe("ValidateSnapshot", () => { - describe("isValidPointType", () => { - it("accepts onCurve", () => { - expect(ValidateSnapshot.isValidPointType("onCurve")).toBe(true); - }); - - it("accepts offCurve", () => { - expect(ValidateSnapshot.isValidPointType("offCurve")).toBe(true); - }); - - it("rejects other strings", () => { - expect(ValidateSnapshot.isValidPointType("curve")).toBe(false); - expect(ValidateSnapshot.isValidPointType("")).toBe(false); - }); - - it("rejects non-strings", () => { - expect(ValidateSnapshot.isValidPointType(null)).toBe(false); - expect(ValidateSnapshot.isValidPointType(undefined)).toBe(false); - expect(ValidateSnapshot.isValidPointType(42)).toBe(false); - expect(ValidateSnapshot.isValidPointType(true)).toBe(false); - }); - }); - - describe("isPointSnapshot", () => { - it("accepts valid point", () => { - expect(ValidateSnapshot.isPointSnapshot(validPoint())).toBe(true); - }); - - it("accepts offCurve point", () => { - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), pointType: "offCurve" })).toBe( - true, - ); - }); - - it("rejects non-object", () => { - expect(ValidateSnapshot.isPointSnapshot(null)).toBe(false); - expect(ValidateSnapshot.isPointSnapshot("string")).toBe(false); - expect(ValidateSnapshot.isPointSnapshot(42)).toBe(false); - }); - - it("rejects missing id", () => { - const { id, ...rest } = validPoint(); - expect(ValidateSnapshot.isPointSnapshot(rest)).toBe(false); - }); - - it("rejects non-finite x", () => { - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), x: Infinity })).toBe(false); - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), x: NaN })).toBe(false); - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), x: "100" })).toBe(false); - }); - - it("rejects non-finite y", () => { - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), y: -Infinity })).toBe(false); - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), y: NaN })).toBe(false); - }); - - it("rejects invalid pointType", () => { - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), pointType: "cubic" })).toBe(false); - }); - - it("rejects non-boolean smooth", () => { - expect(ValidateSnapshot.isPointSnapshot({ ...validPoint(), smooth: 1 })).toBe(false); - }); - }); - - describe("isContourSnapshot", () => { - it("accepts valid contour", () => { - expect(ValidateSnapshot.isContourSnapshot(validContour())).toBe(true); - }); - - it("accepts contour with empty points", () => { - expect(ValidateSnapshot.isContourSnapshot({ ...validContour(), points: [] })).toBe(true); - }); - - it("rejects non-object", () => { - expect(ValidateSnapshot.isContourSnapshot(null)).toBe(false); - expect(ValidateSnapshot.isContourSnapshot([])).toBe(false); - }); - - it("rejects missing id", () => { - const { id, ...rest } = validContour(); - expect(ValidateSnapshot.isContourSnapshot(rest)).toBe(false); - }); - - it("rejects non-boolean closed", () => { - expect(ValidateSnapshot.isContourSnapshot({ ...validContour(), closed: "yes" })).toBe(false); - }); - - it("rejects non-array points", () => { - expect(ValidateSnapshot.isContourSnapshot({ ...validContour(), points: "none" })).toBe(false); - }); - - it("rejects contour with invalid point", () => { - expect( - ValidateSnapshot.isContourSnapshot({ - ...validContour(), - points: [{ x: 0, y: 0 }], - }), - ).toBe(false); - }); - }); - - describe("isGlyphSnapshot", () => { - it("accepts valid glyph", () => { - expect(ValidateSnapshot.isGlyphSnapshot(validGlyph())).toBe(true); - }); - - it("accepts glyph with string activeContourId", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), activeContourId: "c1" })).toBe( - true, - ); - }); - - it("accepts glyph with empty contours", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), contours: [] })).toBe(true); - }); - - it("accepts glyph with empty anchors", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), anchors: [] })).toBe(true); - }); - - it("accepts glyph with empty compositeContours", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), compositeContours: [] })).toBe( - true, - ); - }); - - it("rejects non-object", () => { - expect(ValidateSnapshot.isGlyphSnapshot(null)).toBe(false); - expect(ValidateSnapshot.isGlyphSnapshot("glyph")).toBe(false); - }); - - it("rejects non-finite unicode", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), unicode: NaN })).toBe(false); - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), unicode: Infinity })).toBe(false); - }); - - it("rejects non-string name", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), name: 42 })).toBe(false); - }); - - it("rejects non-finite xAdvance", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), xAdvance: NaN })).toBe(false); - }); - - it("rejects non-array contours", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), contours: {} })).toBe(false); - }); - - it("rejects non-array anchors", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), anchors: {} })).toBe(false); - }); - - it("rejects non-array compositeContours", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), compositeContours: {} })).toBe( - false, - ); - }); - - it("rejects invalid activeContourId type", () => { - expect(ValidateSnapshot.isGlyphSnapshot({ ...validGlyph(), activeContourId: 42 })).toBe( - false, - ); - }); - - it("rejects glyph with invalid contour", () => { - expect( - ValidateSnapshot.isGlyphSnapshot({ - ...validGlyph(), - contours: [{ id: "c1", points: "bad", closed: true }], - }), - ).toBe(false); - }); - - it("rejects glyph with invalid anchor", () => { - expect( - ValidateSnapshot.isGlyphSnapshot({ - ...validGlyph(), - anchors: [{ id: "a1", name: 12, x: 100, y: 200 }], - }), - ).toBe(false); - }); - - it("rejects glyph with invalid composite contour", () => { - expect( - ValidateSnapshot.isGlyphSnapshot({ - ...validGlyph(), - compositeContours: [{ points: "bad", closed: true }], - }), - ).toBe(false); - }); - }); - - describe("glyphSnapshot (detailed)", () => { - it("returns valid for correct glyph", () => { - const result = ValidateSnapshot.glyphSnapshot(validGlyph()); - expect(result.valid).toBe(true); - if (result.valid) { - expect(result.value.name).toBe("A"); - } - }); - - it("returns error for non-object", () => { - const result = ValidateSnapshot.glyphSnapshot("not an object"); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].code).toBe("INVALID_SNAPSHOT_STRUCTURE"); - } - }); - - it("returns error for invalid unicode", () => { - const result = ValidateSnapshot.glyphSnapshot({ ...validGlyph(), unicode: "A" }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].code).toBe("INVALID_SNAPSHOT_STRUCTURE"); - expect(result.errors[0].context?.field).toBe("unicode"); - } - }); - - it("returns error for invalid name", () => { - const result = ValidateSnapshot.glyphSnapshot({ ...validGlyph(), name: null }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("name"); - } - }); - - it("returns error for invalid xAdvance", () => { - const result = ValidateSnapshot.glyphSnapshot({ ...validGlyph(), xAdvance: Infinity }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("xAdvance"); - } - }); - - it("returns error for non-array contours", () => { - const result = ValidateSnapshot.glyphSnapshot({ ...validGlyph(), contours: "bad" }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("contours"); - } - }); - - it("returns error for non-array anchors", () => { - const result = ValidateSnapshot.glyphSnapshot({ ...validGlyph(), anchors: "bad" }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("anchors"); - } - }); - - it("returns error for invalid activeContourId", () => { - const result = ValidateSnapshot.glyphSnapshot({ - ...validGlyph(), - activeContourId: 123, - }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("activeContourId"); - } - }); - - it("returns error for invalid contour at index", () => { - const result = ValidateSnapshot.glyphSnapshot({ - ...validGlyph(), - contours: [{ id: "c1", points: [{ bad: true }], closed: true }], - }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].code).toBe("INVALID_CONTOUR_STRUCTURE"); - expect(result.errors[0].context?.index).toBe(0); - } - }); - - it("returns error for non-array compositeContours", () => { - const result = ValidateSnapshot.glyphSnapshot({ - ...validGlyph(), - compositeContours: "bad", - }); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.errors[0].context?.field).toBe("compositeContours"); - } - }); - }); -}); diff --git a/packages/validation/src/ValidateSnapshot.ts b/packages/validation/src/ValidateSnapshot.ts deleted file mode 100644 index 523a47e4..00000000 --- a/packages/validation/src/ValidateSnapshot.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { - PointType, - PointSnapshot, - ContourSnapshot, - AnchorSnapshot, - RenderPointSnapshot, - RenderContourSnapshot, - GlyphSnapshot, -} from "@shift/types"; -import type { ValidationResult } from "./types"; -import { Validate } from "./Validate"; - -const VALID_POINT_TYPES: ReadonlySet = new Set(["onCurve", "offCurve"]); - -function isRecord(v: unknown): v is Record { - return typeof v === "object" && v !== null && !Array.isArray(v); -} - -export const ValidateSnapshot = { - isValidPointType(v: unknown): v is PointType { - return typeof v === "string" && VALID_POINT_TYPES.has(v); - }, - - isPointSnapshot(v: unknown): v is PointSnapshot { - if (!isRecord(v)) return false; - if (typeof v.id !== "string") return false; - if (typeof v.x !== "number" || !Number.isFinite(v.x)) return false; - if (typeof v.y !== "number" || !Number.isFinite(v.y)) return false; - if (!ValidateSnapshot.isValidPointType(v.pointType)) return false; - if (typeof v.smooth !== "boolean") return false; - return true; - }, - - isContourSnapshot(v: unknown): v is ContourSnapshot { - if (!isRecord(v)) return false; - if (typeof v.id !== "string") return false; - if (typeof v.closed !== "boolean") return false; - if (!Array.isArray(v.points)) return false; - return v.points.every((p: unknown) => ValidateSnapshot.isPointSnapshot(p)); - }, - - isAnchorSnapshot(v: unknown): v is AnchorSnapshot { - if (!isRecord(v)) return false; - if (typeof v.id !== "string") return false; - if (v.name !== null && typeof v.name !== "string") return false; - if (typeof v.x !== "number" || !Number.isFinite(v.x)) return false; - if (typeof v.y !== "number" || !Number.isFinite(v.y)) return false; - return true; - }, - - isRenderPointSnapshot(v: unknown): v is RenderPointSnapshot { - if (!isRecord(v)) return false; - if (typeof v.x !== "number" || !Number.isFinite(v.x)) return false; - if (typeof v.y !== "number" || !Number.isFinite(v.y)) return false; - if (!ValidateSnapshot.isValidPointType(v.pointType)) return false; - if (typeof v.smooth !== "boolean") return false; - return true; - }, - - isRenderContourSnapshot(v: unknown): v is RenderContourSnapshot { - if (!isRecord(v)) return false; - if (typeof v.closed !== "boolean") return false; - if (!Array.isArray(v.points)) return false; - return v.points.every((p: unknown) => ValidateSnapshot.isRenderPointSnapshot(p)); - }, - - isGlyphSnapshot(v: unknown): v is GlyphSnapshot { - if (!isRecord(v)) return false; - if (typeof v.unicode !== "number" || !Number.isFinite(v.unicode)) return false; - if (typeof v.name !== "string") return false; - if (typeof v.xAdvance !== "number" || !Number.isFinite(v.xAdvance)) return false; - if (!Array.isArray(v.contours)) return false; - if (!Array.isArray(v.anchors)) return false; - if (!Array.isArray(v.compositeContours)) return false; - if (v.activeContourId !== null && typeof v.activeContourId !== "string") return false; - return ( - v.contours.every((c: unknown) => ValidateSnapshot.isContourSnapshot(c)) && - v.anchors.every((a: unknown) => ValidateSnapshot.isAnchorSnapshot(a)) && - v.compositeContours.every((c: unknown) => ValidateSnapshot.isRenderContourSnapshot(c)) - ); - }, - - glyphSnapshot(v: unknown): ValidationResult { - if (!isRecord(v)) { - return Validate.fail(Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Expected an object")); - } - - if (typeof v.unicode !== "number" || !Number.isFinite(v.unicode)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid or missing 'unicode' field", { - field: "unicode", - value: v.unicode, - }), - ); - } - - if (typeof v.name !== "string") { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid or missing 'name' field", { - field: "name", - value: v.name, - }), - ); - } - - if (typeof v.xAdvance !== "number" || !Number.isFinite(v.xAdvance)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid or missing 'xAdvance' field", { - field: "xAdvance", - value: v.xAdvance, - }), - ); - } - - if (!Array.isArray(v.contours)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid or missing 'contours' field", { - field: "contours", - }), - ); - } - - if (!Array.isArray(v.anchors)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid or missing 'anchors' field", { - field: "anchors", - }), - ); - } - - if (!Array.isArray(v.compositeContours)) { - return Validate.fail( - Validate.error( - "INVALID_SNAPSHOT_STRUCTURE", - "Invalid or missing 'compositeContours' field", - { - field: "compositeContours", - }, - ), - ); - } - - if (v.activeContourId !== null && typeof v.activeContourId !== "string") { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", "Invalid 'activeContourId' field", { - field: "activeContourId", - value: v.activeContourId, - }), - ); - } - - for (let i = 0; i < v.contours.length; i++) { - const contour = v.contours[i]; - if (!ValidateSnapshot.isContourSnapshot(contour)) { - return Validate.fail( - Validate.error("INVALID_CONTOUR_STRUCTURE", `Invalid contour at index ${i}`, { - index: i, - }), - ); - } - } - - for (let i = 0; i < v.anchors.length; i++) { - const anchor = v.anchors[i]; - if (!ValidateSnapshot.isAnchorSnapshot(anchor)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", `Invalid anchor at index ${i}`, { - index: i, - }), - ); - } - } - - for (let i = 0; i < v.compositeContours.length; i++) { - const contour = v.compositeContours[i]; - if (!ValidateSnapshot.isRenderContourSnapshot(contour)) { - return Validate.fail( - Validate.error("INVALID_SNAPSHOT_STRUCTURE", `Invalid composite contour at index ${i}`, { - index: i, - }), - ); - } - } - - return Validate.ok(v as GlyphSnapshot); - }, -} as const; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 8a671af6..04716af9 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -22,13 +22,10 @@ */ export { Validate } from "./Validate"; -export { ValidateSnapshot } from "./ValidateSnapshot"; export { ValidateClipboard } from "./ValidateClipboard"; export { PersistedTextRunSchema, TextRunModuleSchema, - SnapPreferencesSchema, - UserPreferencesSchema, PersistedModuleEnvelopeSchema, PersistenceRegistrySchema, PersistedDocumentSchema, @@ -39,8 +36,6 @@ export type { ValidationResult, ValidationError, ValidationErrorCode, PointLike export type { PersistedTextRun, TextRunModule, - SnapPreferencesShape, - UserPreferences, PersistedModuleEnvelope, PersistenceRegistry, PersistedDocument, diff --git a/packages/validation/src/persistence.test.ts b/packages/validation/src/persistence.test.ts index ca41f858..a979a231 100644 --- a/packages/validation/src/persistence.test.ts +++ b/packages/validation/src/persistence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { PersistedRootSchema, TextRunModuleSchema, UserPreferencesSchema } from "./persistence"; +import { PersistedRootSchema, TextRunModuleSchema } from "./persistence"; describe("persistence schemas", () => { it("accepts a valid persisted root payload", () => { @@ -12,19 +12,7 @@ describe("persistence schemas", () => { lruDocIds: ["doc-1"], }, appModules: { - "user-preferences": { - moduleVersion: 1, - payload: { - snap: { - enabled: true, - angle: true, - metrics: true, - pointToPoint: true, - angleIncrementDeg: 45, - pointRadiusPx: 8, - }, - }, - }, + "user-preferences": { moduleVersion: 1, payload: {} }, }, documents: { "doc-1": { @@ -74,19 +62,4 @@ describe("persistence schemas", () => { expect(result.success).toBe(false); }); - - it("rejects invalid user preferences payload", () => { - const result = UserPreferencesSchema.safeParse({ - snap: { - enabled: true, - angle: true, - metrics: true, - pointToPoint: true, - angleIncrementDeg: "45", - pointRadiusPx: 8, - }, - }); - - expect(result.success).toBe(false); - }); }); diff --git a/packages/validation/src/persistence.ts b/packages/validation/src/persistence.ts index 1ca49183..9223d2ed 100644 --- a/packages/validation/src/persistence.ts +++ b/packages/validation/src/persistence.ts @@ -29,19 +29,6 @@ export const TextRunModuleSchema = z.object({ runsByGlyph: z.record(z.string(), PersistedTextRunSchema), }); -export const SnapPreferencesSchema = z.object({ - enabled: z.boolean(), - angle: z.boolean(), - metrics: z.boolean(), - pointToPoint: z.boolean(), - angleIncrementDeg: z.number().finite(), - pointRadiusPx: z.number().finite(), -}); - -export const UserPreferencesSchema = z.object({ - snap: SnapPreferencesSchema, -}); - export const PersistedModuleEnvelopeSchema = z.object({ moduleVersion: z.number().int().nonnegative(), payload: z.unknown(), @@ -69,8 +56,6 @@ export const PersistedRootSchema = z.object({ export type PersistedTextRun = z.infer; export type TextRunModule = z.infer; -export type SnapPreferencesShape = z.infer; -export type UserPreferences = z.infer; export type PersistedModuleEnvelope = z.infer; export type PersistenceRegistry = z.infer; export type PersistedDocument = z.infer; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts index 38f8e492..7ec93e4a 100644 --- a/packages/validation/src/types.ts +++ b/packages/validation/src/types.ts @@ -17,10 +17,6 @@ export type ValidationErrorCode = | "TOO_MANY_CONSECUTIVE_OFF_CURVE" | "ORPHAN_OFF_CURVE" | "INCOMPLETE_SEGMENT" - | "INVALID_SNAPSHOT_STRUCTURE" - | "INVALID_CONTOUR_STRUCTURE" - | "INVALID_POINT_STRUCTURE" - | "INVALID_POINT_TYPE" | "INVALID_CLIPBOARD_CONTENT"; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b071d489..34b1ef32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,15 +48,15 @@ importers: '@shift/bridge': specifier: workspace:* version: link:../../packages/bridge - '@shift/font': - specifier: workspace:* - version: link:../../packages/font '@shift/geo': specifier: workspace:* version: link:../../packages/geo '@shift/glyph-info': specifier: workspace:* version: link:../../packages/glyph-info + '@shift/glyph-state': + specifier: workspace:* + version: link:../../packages/glyph-state '@shift/rules': specifier: workspace:* version: link:../../packages/rules @@ -220,22 +220,6 @@ importers: specifier: ^5.5.4 version: 5.9.3 - packages/font: - dependencies: - '@shift/geo': - specifier: workspace:* - version: link:../geo - '@shift/types': - specifier: workspace:* - version: link:../types - devDependencies: - typescript: - specifier: ^5.5.4 - version: 5.9.3 - vitest: - specifier: ^4.0.17 - version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) - packages/geo: dependencies: '@shift/types': @@ -268,11 +252,24 @@ importers: specifier: ^4.0.17 version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) - packages/rules: + packages/glyph-state: dependencies: - '@shift/font': + '@shift/geo': + specifier: workspace:* + version: link:../geo + '@shift/types': specifier: workspace:* - version: link:../font + version: link:../types + devDependencies: + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.0.17 + version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/rules: + dependencies: '@shift/geo': specifier: workspace:* version: link:../geo diff --git a/scripts/context-drift-check.py b/scripts/context-drift-check.py index 971b3363..e9d6d4e2 100755 --- a/scripts/context-drift-check.py +++ b/scripts/context-drift-check.py @@ -29,10 +29,10 @@ # All known DOCS.md locations (must match routing index) EXPECTED_DOCS = [ - "crates/shift-core/docs/DOCS.md", + "crates/shift-edit/docs/DOCS.md", "crates/shift-backends/docs/DOCS.md", "crates/shift-ir/docs/DOCS.md", - "crates/shift-node/docs/DOCS.md", + "crates/shift-bridge/docs/DOCS.md", "apps/desktop/src/main/docs/DOCS.md", "apps/desktop/src/preload/docs/DOCS.md", "apps/desktop/src/shared/bridge/docs/DOCS.md", @@ -45,7 +45,7 @@ "apps/desktop/src/renderer/src/lib/reactive/docs/DOCS.md", "packages/types/docs/DOCS.md", "packages/geo/docs/DOCS.md", - "packages/font/docs/DOCS.md", + "packages/glyph-state/docs/DOCS.md", "packages/ui/docs/DOCS.md", "packages/validation/docs/DOCS.md", "packages/rules/docs/DOCS.md", @@ -432,7 +432,7 @@ def _load_valid_commands() -> dict: pnpm[name] = set(data.get("scripts", {}).keys()) # Also index the napi crate by its npm name - node_pkg = REPO_ROOT / "crates" / "shift-node" / "package.json" + node_pkg = REPO_ROOT / "crates" / "shift-bridge" / "package.json" if node_pkg.exists(): data = json_mod.loads(node_pkg.read_text()) name = data.get("name", "") diff --git a/scripts/generate-bridge-types.mjs b/scripts/generate-bridge-types.mjs index bae1e280..bcc5a27d 100644 --- a/scripts/generate-bridge-types.mjs +++ b/scripts/generate-bridge-types.mjs @@ -45,8 +45,8 @@ function withoutNapiPrefix(text) { } function enumToUnion(name, body) { - const values = [...body.matchAll(/=\s*'([^']+)'/g)].map((match) => `'${match[1]}'`); - return `export type ${withoutNapiPrefix(name)} = ${values.join(" | ")}\n`; + const values = [...body.matchAll(/=\s*["']([^"']+)["']/g)].map((match) => `"${match[1]}"`); + return `export type ${withoutNapiPrefix(name)} = ${values.join(" | ")};\n`; } let body = source.replace(idImportMatch?.[0] ?? "", ""); @@ -72,17 +72,18 @@ body = body.replace( body = withoutNapiPrefix(body); body = body.replace(/\bexport declare /g, "export "); -const imports = [ - idImports.length > 0 - ? `import type {\n ${idImports.join(",\n ")},\n} from "../ids";\n` - : "", -] - .filter(Boolean) - .join("") + (idImports.length > 0 ? "\n" : ""); +const imports = + [idImports.length > 0 ? `import type {\n ${idImports.join(",\n ")},\n} from "../ids";\n` : ""] + .filter(Boolean) + .join("") + (idImports.length > 0 ? "\n" : ""); const aliases = scalarAliases.length > 0 ? `${scalarAliases.join("\n")}\n\n` : ""; -const output = `${imports}// This file is generated from crates/shift-bridge/index.d.ts.\n// Run \`pnpm generate:bridge-types\` after rebuilding shift-bridge declarations.\n// Do not edit this file manually.\n\n${aliases}${body}`; +const output = + `${imports}// This file is generated from crates/shift-bridge/index.d.ts.\n// Run \`pnpm generate:bridge-types\` after rebuilding shift-bridge declarations.\n// Do not edit this file manually.\n\n${aliases}${body}`.replace( + /\n{3,}/g, + "\n\n", + ); mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, output); diff --git a/scripts/oxlint/shift-plugin.mjs b/scripts/oxlint/shift-plugin.mjs index 65b25856..70458937 100644 --- a/scripts/oxlint/shift-plugin.mjs +++ b/scripts/oxlint/shift-plugin.mjs @@ -8,7 +8,7 @@ */ /** Files where raw .pointType checks are expected (validation implementation). */ -const POINT_TYPE_ALLOWED = ["packages/validation/", "packages/font/", "packages/rules/"]; +const POINT_TYPE_ALLOWED = ["packages/validation/", "packages/glyph-state/", "packages/rules/"]; /** Files where direct .contours access is expected (structural traversal). */ const CONTOURS_ALLOWED = [ @@ -16,7 +16,7 @@ const CONTOURS_ALLOWED = [ "bridge/NativeBridge.ts", "bridge/glyph.ts", "lib/model/", - "packages/font/", + "packages/glyph-state/", "rendering/", // render passes iterate contours to draw them "types/selection.ts", // selection-bounds computed unions per-contour bounds "hit/composite.ts", // component contour bounds check @@ -196,7 +196,7 @@ export default { /** * Prefer instance methods on the Glyph / Contour domain classes over the - * static namespaces from @shift/font when the receiver is a class instance. + * static namespaces from @shift/glyph-state when the receiver is a class instance. * * Swaps: * Glyphs.findPoint(glyph, id) → glyph.point(id) @@ -225,7 +225,7 @@ export default { const PREFER_INSTANCE_ALLOWED = [ "lib/model/", // domain classes delegate to these internally - "packages/font/", // implementation + "packages/glyph-state/", // implementation "bridge/", // operates on GlyphSnapshot, not class instances "commands/", // undo/redo snapshots "behaviors/", // drag handlers read base snapshots @@ -302,7 +302,7 @@ export default { const SEGMENT_PARSE_ALLOWED = [ "lib/model/", // Segment.parse + Contour.segments wrap parseContourSegments here - "packages/font/", // parseContourSegments lives here + "packages/glyph-state/", // parseContourSegments lives here "testing/", ]; From 272128f2f7856ec3cee9ae55ff0e73b6bda6947b Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 20 May 2026 08:40:50 +0100 Subject: [PATCH 09/13] refactor: rebuild editor around source-aware rendering Rework the editor architecture around explicit source/preview glyph state, reactive render models, and tool-owned interaction state. - split editor state into focused domains for glyph/source context, camera/input, selection, hover, text editing, and glyph display - replace viewport/rendering manager paths with Camera, Renderer, render frames, canvas surfaces, and CanvasItem render boundaries - move selection, hover, hit, bounding box, marquee, and segment affordance logic out of loose renderer/types modules and into editor or tool-owned surfaces - introduce source-aware glyph state, sparse position patches, render models, keyed cache support, and outline/path invalidation so edit previews can update without rebuilding full geometry - update select and pen tools to use richer domain hit/query surfaces and tool-local drawing components - consolidate handle rendering around overlay/marker backends and keep marker upload ownership inside rendering infrastructure - add signal debugging, dependency naming, agent skill sync, jsdoc guidance, and focused tests for source geometry freshness, drafts, selection, camera, bounding boxes, and rendering primitives - update Rust bridge/edit-session paths for sparse position patching and source-aware glyph synchronization --- .agent-skills/jsdoc/SKILL.md | 343 ++++ .claude/settings.json | 2 +- .claude/skills/docs/SKILL.md | 3 +- .claude/skills/jsdoc/SKILL.md | 343 ++++ .claude/skills/writing-tests/SKILL.md | 6 + .codex/skills/jsdoc/SKILL.md | 343 ++++ .editorconfig | 15 + .oxfmtrc.json | 28 + .pre-commit-config.yaml | 1 + .prettierignore | 13 - .prettierrc | 10 - apps/desktop/.oxlintrc.json | 7 +- apps/desktop/e2e/perf.spec.ts | 79 +- .../src/main/managers/WindowManager.ts | 1 + apps/desktop/src/renderer/index.css | 12 +- apps/desktop/src/renderer/src/app/App.tsx | 69 +- .../src/renderer/src/app/Document.test.ts | 119 ++ apps/desktop/src/renderer/src/app/Document.ts | 117 ++ .../src/renderer/src/app/WorkspaceLayout.tsx | 37 +- apps/desktop/src/renderer/src/app/routes.ts | 15 +- .../src/assets/sidebar-left/sidebar-left.svg | 4 + .../src/components/chrome/FontInfoDialog.tsx | 158 ++ .../src/components/chrome/NavigationPane.tsx | 42 +- .../src/components/debug/DebugPanel.tsx | 100 +- .../src/components/editor/EditorView.tsx | 29 +- .../src/components/editor/GlyphFinder.tsx | 21 +- .../src/components/editor/LeftSidebar.tsx | 2 +- .../src/components/editor/RightSidebar.tsx | 11 +- .../src/components/editor/StaticScene.tsx | 7 +- .../src/components/editor/ToolsPane.tsx | 58 +- .../editor/sidebar-right/AnchorSection.tsx | 18 +- .../editor/sidebar-right/TransformSection.tsx | 37 +- .../src/components/home/GlyphPreview.tsx | 57 +- .../src/components/home/LeftSidebar.tsx | 3 +- .../home/glyph-catalog/Category.tsx | 9 +- .../home/glyph-catalog/GlyphCatalog.tsx | 25 +- .../home/glyph-catalog/SubCategory.tsx | 14 +- .../src/components/text/HiddenTextInput.tsx | 10 +- .../src/components/variation/AxesPanel.tsx | 48 +- .../src/components/variation/Sources.tsx | 9 +- .../renderer/src/context/CanvasContext.tsx | 136 +- .../src/context/GlyphCatalogContext.tsx | 48 +- .../renderer/src/hooks/useActiveSourceId.ts | 6 - .../src/renderer/src/hooks/useEditSourceId.ts | 6 + .../src/hooks/useGlyphSidebearings.ts | 7 +- .../renderer/src/hooks/useSelectionBounds.ts | 7 +- .../src/renderer/src/hooks/useSources.ts | 3 +- .../commands/clipboard/ClipboardCommands.ts | 13 +- .../renderer/src/lib/commands/docs/DOCS.md | 10 +- .../src/renderer/src/lib/commands/index.ts | 8 +- ...t.ts => ApplyPositionPatchCommand.test.ts} | 63 +- ...ommand.ts => ApplyPositionPatchCommand.ts} | 23 +- .../lib/commands/primitives/BezierCommands.ts | 123 +- .../lib/commands/primitives/PointCommands.ts | 11 +- .../lib/commands/primitives/ShapeCommands.ts | 51 +- .../src/lib/commands/primitives/index.ts | 7 +- .../renderer/src/lib/commands/testUtils.ts | 24 +- .../renderer/src/lib/editor/Editor.test.ts | 80 +- .../src/renderer/src/lib/editor/Editor.ts | 1478 +++++++---------- .../renderer/src/lib/editor/EditorState.ts | 338 ++++ .../src/renderer/src/lib/editor/Hover.ts | 78 + .../editor/Selection.test.ts} | 93 +- .../src/renderer/src/lib/editor/Selection.ts | 245 +++ .../src/lib/editor/SourceEditDraft.test.ts | 84 +- .../src/lib/editor/SourceEditDraft.ts | 39 +- .../src/renderer/src/lib/editor/docs/DOCS.md | 93 +- .../src/lib/editor/hit/boundingBox.test.ts | 273 --- .../src/lib/editor/hit/boundingBox.ts | 204 --- .../src/lib/editor/managers/Camera.test.ts | 526 ++++++ .../src/lib/editor/managers/Camera.ts | 376 +++++ .../src/lib/editor/managers/EdgePanManager.ts | 3 - .../lib/editor/managers/HoverManager.test.ts | 195 --- .../src/lib/editor/managers/HoverManager.ts | 140 -- .../editor/managers/ViewportManager.test.ts | 441 ----- .../lib/editor/managers/ViewportManager.ts | 240 --- .../renderer/src/lib/editor/managers/index.ts | 3 +- .../src/lib/editor/rendering/Canvas.ts | 65 +- .../src/lib/editor/rendering/CanvasItem.ts | 39 + .../src/lib/editor/rendering/CanvasSurface.ts | 90 + .../src/lib/editor/rendering/Handles.ts | 120 -- .../src/lib/editor/rendering/Outline.ts | 26 + .../src/lib/editor/rendering/RenderFrame.ts | 371 +++++ .../src/lib/editor/rendering/Renderer.ts | 207 +++ .../renderer/src/lib/editor/rendering/Text.ts | 57 +- .../src/lib/editor/rendering/Theme.ts | 61 +- .../src/lib/editor/rendering/Viewport.ts | 188 --- .../editor/rendering/gpu/classifyHandles.ts | 126 -- .../src/lib/editor/rendering/gpu/types.ts | 32 - .../editor/rendering/indicators/Anchors.ts | 20 - .../rendering/indicators/BoundingBox.ts | 30 - .../rendering/{gpu => markers}/color.ts | 3 +- .../{gpu => markers}/handleStyles.ts | 22 +- .../shaders/handle.frag.glsl.ts | 0 .../shaders/handle.vert.glsl.ts | 3 +- .../{gpu => markers}/shaders/sdf.glsl.ts | 0 .../src/lib/editor/rendering/markers/types.ts | 27 + .../lib/editor/rendering/overlays/Anchors.ts | 37 + .../{indicators => overlays}/ControlLines.ts | 15 +- .../{indicators => overlays}/DebugOverlays.ts | 36 +- .../{indicators => overlays}/Guides.ts | 0 .../lib/editor/rendering/overlays/Handles.ts | 70 + .../{indicators => overlays}/Segments.ts | 20 +- .../{indicators => overlays}/handleDrawing.ts | 6 +- .../overlays/handles/CanvasHandleRenderer.ts | 30 + .../rendering/overlays/handles/HandleItems.ts | 82 + .../overlays/handles/MarkerHandleRenderer.ts | 99 ++ .../overlays/handles/PointHandleItem.ts | 87 + .../{indicators => overlays}/index.ts | 9 +- .../editor/rendering/visibleSceneBounds.ts | 31 - .../renderer/src/lib/editor/variation.test.ts | 15 +- .../backends/{Gpu.ts => MarkerLayer.ts} | 166 +- .../renderer/src/lib/graphics/docs/DOCS.md | 56 +- .../src/lib/keyboard/KeyboardRouter.test.ts | 27 +- .../src/lib/keyboard/KeyboardRouter.ts | 36 +- .../src/renderer/src/lib/keyboard/keymaps.ts | 28 +- .../src/renderer/src/lib/keyboard/types.ts | 5 +- .../src/renderer/src/lib/model/Font.test.ts | 44 +- .../src/renderer/src/lib/model/Font.ts | 236 ++- .../src/renderer/src/lib/model/Glyph.test.ts | 162 +- .../src/renderer/src/lib/model/Glyph.ts | 1127 +++++++++++-- .../renderer/src/lib/model/GlyphOutline.ts | 611 +++++-- .../src/lib/model/GlyphRenderModel.ts | 292 ++++ .../src/lib/model/GlyphSourceState.test.ts | 130 ++ .../src/lib/model/GlyphSourceState.ts | 544 ++++++ .../src/lib/model/SourcePositionList.ts | 107 +- .../src/lib/model/SourcePositionPatch.ts | 59 + .../src/lib/signals/KeyedCache.test.ts | 90 + .../renderer/src/lib/signals/KeyedCache.ts | 115 ++ .../src/renderer/src/lib/signals/docs/DOCS.md | 2 +- .../src/renderer/src/lib/signals/index.ts | 24 +- .../renderer/src/lib/signals/signal.test.ts | 216 ++- .../src/renderer/src/lib/signals/signal.ts | 538 +++++- .../src/renderer/src/lib/signals/useSignal.ts | 45 +- .../src/renderer/src/lib/state/ShiftState.ts | 1 + .../renderer/src/lib/text/TextBuffer.test.ts | 32 +- .../src/renderer/src/lib/text/TextBuffer.ts | 161 +- .../src/lib/text/TextInteraction.test.ts | 24 +- .../renderer/src/lib/text/TextInteraction.ts | 78 +- .../src/renderer/src/lib/text/TextRun.ts | 189 ++- .../src/renderer/src/lib/text/TextRuns.ts | 53 +- .../src/renderer/src/lib/text/docs/DOCS.md | 28 +- .../src/lib/text/layout/Caret.test.ts | 20 +- .../src/renderer/src/lib/text/layout/Caret.ts | 27 +- .../src/lib/text/layout/Positioner.test.ts | 37 +- .../src/lib/text/layout/Positioner.ts | 38 +- .../src/lib/text/layout/TextLayout.test.ts | 61 +- .../src/lib/text/layout/TextLayout.ts | 92 +- .../src/renderer/src/lib/text/layout/index.ts | 10 +- .../renderer/src/lib/text/layout/testUtils.ts | 11 +- .../src/renderer/src/lib/text/layout/types.ts | 42 +- .../renderer/src/lib/tools/core/BaseTool.ts | 134 +- .../renderer/src/lib/tools/core/Behavior.ts | 77 +- .../src/lib/tools/core/ToolManager.test.ts | 58 +- .../src/lib/tools/core/ToolManager.ts | 100 +- .../src/lib/tools/core/ToolManifest.ts | 2 +- .../src/renderer/src/lib/tools/docs/DOCS.md | 34 +- .../tools/hand/behaviors/DraggingBehavior.ts | 7 +- .../src/renderer/src/lib/tools/pen/Pen.ts | 154 +- .../renderer/src/lib/tools/pen/PenPreview.ts | 94 ++ .../tools/pen/{behaviors => }/PenStroke.ts | 84 +- .../renderer/src/lib/tools/pen/PenTargets.ts | 95 ++ .../tools/pen/behaviors/CancelBehaviour.ts | 8 +- .../pen/behaviors/DragHandlesBehaviour.ts | 38 +- .../tools/pen/behaviors/PenDownBehaviour.ts | 79 +- .../src/renderer/src/lib/tools/pen/types.ts | 2 +- .../src/lib/tools/select/BoundingBox.test.ts | 185 +++ .../src/lib/tools/select/BoundingBox.ts | 519 ++++++ .../renderer/src/lib/tools/select/Marquee.ts | 44 + .../renderer/src/lib/tools/select/Segments.ts | 41 + .../renderer/src/lib/tools/select/Select.ts | 112 +- .../lib/tools/select/behaviors/BendCurve.ts | 65 +- .../select/behaviors/ContourDoubleClick.ts | 42 - .../src/lib/tools/select/behaviors/Escape.ts | 2 +- .../src/lib/tools/select/behaviors/Marquee.ts | 75 +- .../src/lib/tools/select/behaviors/Nudge.ts | 8 +- .../src/lib/tools/select/behaviors/Resize.ts | 128 +- .../src/lib/tools/select/behaviors/Rotate.ts | 81 +- .../select/behaviors/SegmentDoubleClick.ts | 29 + .../lib/tools/select/behaviors/SelectHover.ts | 59 + .../lib/tools/select/behaviors/Selection.ts | 180 +- .../lib/tools/select/behaviors/TextRunEdit.ts | 8 +- .../tools/select/behaviors/TextRunHover.ts | 6 +- .../tools/select/behaviors/ToggleSmooth.ts | 18 +- .../lib/tools/select/behaviors/Translate.ts | 250 ++- .../tools/select/behaviors/UpgradeSegment.ts | 28 +- .../src/lib/tools/select/behaviors/index.ts | 3 +- .../renderer/src/lib/tools/select/cursor.ts | 22 - .../renderer/src/lib/tools/select/index.ts | 10 +- .../renderer/src/lib/tools/select/types.ts | 13 +- .../renderer/src/lib/tools/select/utils.ts | 21 - .../src/renderer/src/lib/tools/shape/Shape.ts | 25 +- .../src/renderer/src/lib/tools/text/Text.ts | 14 +- .../renderer/src/lib/utils/unicode.test.ts | 16 +- .../src/renderer/src/lib/utils/unicode.ts | 27 +- .../renderer/src/perf/interaction.bench.ts | 2 +- .../renderer/src/perf/napiBoundary.bench.ts | 10 +- .../src/perf/pointManipulation.bench.ts | 22 +- .../src/renderer/src/perf/rendering.bench.ts | 39 +- .../src/renderer/src/persistence/kernel.ts | 43 +- apps/desktop/src/renderer/src/store/store.ts | 40 +- .../renderer/src/testing/TestEditor.test.ts | 11 +- .../src/renderer/src/testing/TestEditor.ts | 30 +- .../src/renderer/src/testing/stubCanvas.ts | 17 +- .../src/renderer/src/types/boundingBox.ts | 20 - apps/desktop/src/renderer/src/types/editor.ts | 2 - .../src/renderer/src/types/hitResult.ts | 114 -- .../src/renderer/src/types/selection.ts | 320 ---- .../desktop/src/renderer/src/views/Editor.tsx | 72 +- .../src/renderer/src/views/FontInfo.tsx | 28 - apps/desktop/src/renderer/src/views/Home.tsx | 43 +- .../src/renderer/src/views/Landing.tsx | 23 +- apps/desktop/vitest.config.ts | 2 + .../shift-backends/src/designspace/reader.rs | 1 + crates/shift-backends/src/glyphs/reader.rs | 2 +- crates/shift-bridge/__test__/index.spec.mjs | 36 +- crates/shift-bridge/index.d.ts | 3 +- crates/shift-bridge/src/bridge.rs | 68 +- crates/shift-edit/src/edit_session.rs | 36 +- crates/shift-ir/src/font.rs | 39 +- package.json | 10 +- packages/geo/src/Rect.test.ts | 50 + packages/geo/src/Rect.ts | 51 + packages/geo/src/index.ts | 11 +- packages/glyph-state/docs/DOCS.md | 2 +- packages/glyph-state/src/Anchor.ts | 36 +- packages/glyph-state/src/Component.ts | 55 +- packages/glyph-state/src/Contour.ts | 98 +- .../glyph-state/src/GlyphGeometry.test.ts | 126 -- packages/glyph-state/src/GlyphGeometry.ts | 518 ++++-- .../glyph-state/src/GlyphStateGeometry.ts | 184 -- packages/glyph-state/src/IdIndex.ts | 38 + packages/glyph-state/src/Point.ts | 113 ++ packages/glyph-state/src/Segment.test.ts | 159 -- packages/glyph-state/src/Segment.ts | 403 +++-- packages/glyph-state/src/index.ts | 40 +- packages/rules/docs/DOCS.md | 2 +- packages/types/src/bridge/generated.ts | 3 +- packages/ui/package.json | 1 + packages/ui/src/components/button/Button.tsx | 15 +- .../ui/src/components/resizable/Resizable.tsx | 59 + packages/ui/src/components/resizable/index.ts | 9 + packages/ui/src/index.ts | 16 +- packages/validation/src/persistence.test.ts | 18 +- packages/validation/src/persistence.ts | 15 +- pnpm-lock.yaml | 238 ++- scripts/oxlint/shift-plugin.mjs | 305 +++- scripts/sync-agent-skills.mjs | 105 ++ 247 files changed, 14950 insertions(+), 7038 deletions(-) create mode 100644 .agent-skills/jsdoc/SKILL.md create mode 100644 .claude/skills/jsdoc/SKILL.md create mode 100644 .codex/skills/jsdoc/SKILL.md create mode 100644 .editorconfig create mode 100644 .oxfmtrc.json delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 apps/desktop/src/renderer/src/app/Document.test.ts create mode 100644 apps/desktop/src/renderer/src/app/Document.ts create mode 100644 apps/desktop/src/renderer/src/assets/sidebar-left/sidebar-left.svg create mode 100644 apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx delete mode 100644 apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts create mode 100644 apps/desktop/src/renderer/src/hooks/useEditSourceId.ts rename apps/desktop/src/renderer/src/lib/commands/primitives/{SetSourcePositionsCommand.test.ts => ApplyPositionPatchCommand.test.ts} (57%) rename apps/desktop/src/renderer/src/lib/commands/primitives/{SetSourcePositionsCommand.ts => ApplyPositionPatchCommand.ts} (52%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/EditorState.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/Hover.ts rename apps/desktop/src/renderer/src/{types/selection.test.ts => lib/editor/Selection.test.ts} (67%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/Selection.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/hit/boundingBox.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/Camera.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/Camera.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/HoverManager.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/managers/ViewportManager.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/CanvasItem.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/CanvasSurface.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Handles.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Outline.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Renderer.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Viewport.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/gpu/classifyHandles.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/gpu/types.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/indicators/Anchors.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/indicators/BoundingBox.ts rename apps/desktop/src/renderer/src/lib/editor/rendering/{gpu => markers}/color.ts (94%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{gpu => markers}/handleStyles.ts (85%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{gpu => markers}/shaders/handle.frag.glsl.ts (100%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{gpu => markers}/shaders/handle.vert.glsl.ts (94%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{gpu => markers}/shaders/sdf.glsl.ts (100%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/markers/types.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Anchors.ts rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/ControlLines.ts (74%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/DebugOverlays.ts (72%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/Guides.ts (100%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/Segments.ts (79%) rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/handleDrawing.ts (98%) create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/CanvasHandleRenderer.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/HandleItems.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/MarkerHandleRenderer.ts create mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/PointHandleItem.ts rename apps/desktop/src/renderer/src/lib/editor/rendering/{indicators => overlays}/index.ts (58%) delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/visibleSceneBounds.ts rename apps/desktop/src/renderer/src/lib/graphics/backends/{Gpu.ts => MarkerLayer.ts} (50%) create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphRenderModel.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSourceState.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSourceState.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/SourcePositionPatch.ts create mode 100644 apps/desktop/src/renderer/src/lib/signals/KeyedCache.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/signals/KeyedCache.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/pen/PenPreview.ts rename apps/desktop/src/renderer/src/lib/tools/pen/{behaviors => }/PenStroke.ts (62%) create mode 100644 apps/desktop/src/renderer/src/lib/tools/pen/PenTargets.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/Marquee.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/Segments.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/select/behaviors/ContourDoubleClick.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/behaviors/SegmentDoubleClick.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/behaviors/SelectHover.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/select/utils.ts delete mode 100644 apps/desktop/src/renderer/src/types/boundingBox.ts delete mode 100644 apps/desktop/src/renderer/src/types/hitResult.ts delete mode 100644 apps/desktop/src/renderer/src/types/selection.ts delete mode 100644 apps/desktop/src/renderer/src/views/FontInfo.tsx create mode 100644 packages/geo/src/Rect.test.ts create mode 100644 packages/geo/src/Rect.ts delete mode 100644 packages/glyph-state/src/GlyphGeometry.test.ts delete mode 100644 packages/glyph-state/src/GlyphStateGeometry.ts create mode 100644 packages/glyph-state/src/IdIndex.ts create mode 100644 packages/glyph-state/src/Point.ts delete mode 100644 packages/glyph-state/src/Segment.test.ts create mode 100644 packages/ui/src/components/resizable/Resizable.tsx create mode 100644 packages/ui/src/components/resizable/index.ts create mode 100644 scripts/sync-agent-skills.mjs diff --git a/.agent-skills/jsdoc/SKILL.md b/.agent-skills/jsdoc/SKILL.md new file mode 100644 index 00000000..5521445d --- /dev/null +++ b/.agent-skills/jsdoc/SKILL.md @@ -0,0 +1,343 @@ +--- +name: jsdoc +description: Add or revise source-level JSDoc for Shift APIs. Use this skill before writing or editing documentation comments for exported classes, methods, constructors, domain data structures, render frames, reactive state, or any API where caller intent, side effects, lifetime, ownership, or nullability are easy to misunderstand. +--- + +# /jsdoc — Source API Contracts + +Write JSDoc as the stable public contract a caller needs without reading the implementation. + +JSDoc comments must sit immediately before the documented symbol and use `/** ... */` so tooling can parse them. Follow the standard tag vocabulary from , adapted for TypeScript source. + +## Why this matters + +- **VS Code hover** renders the first line in bold and the rest as body. A one-sentence contract on line 1 is the single highest-leverage thing you can write. +- **TypeScript already encodes shape.** JSDoc is for what types can't say: ownership, lifetime, mutation, side effects, side-channel reactivity, nullability semantics, performance class, call ordering. +- **Code review** is the second reader. Reviewers should be able to judge a call site against the doc without opening the implementation. + +## Runbook + +1. Identify the audience: caller, implementer, renderer, tool author, or maintainer. +2. State the stable contract in one short opening sentence. +3. Add details only for ownership, lifetime, reactivity, mutation, side effects, nullability, performance, or call ordering. Put long detail under `@remarks`. +4. Use tags for callable APIs: + - Add `@param` for every public constructor, method, or function parameter, and make the text describe the parameter's role, constraint, ownership, or valid range. + - Add `@returns` when a method returns a value, nullable result, created object, snapshot, or read-only view. + - Add `@throws {ErrorType} when …` for every observable failure mode (custom error class, semantic Error). + - Do not add `@returns void`; describe the side effect in prose instead. +5. Cross-reference siblings with `@see {@link OtherSymbol}` (one tag per related symbol). +6. Add `@example` only when the intended call flow, ordering, or output is not obvious from the signature. +7. Re-read the comment and delete implementation trivia, current call-site anecdotes, and unstable examples. + +## Hard Style Rules + +- **One-line contract.** First sentence is a verb phrase (`Returns…`, `Applies…`, `Triggers…`), ending with a period. No "This function…". +- **`@remarks` for the long explanation.** If you need more than one sentence of context, demote it under `@remarks` so the hover summary stays clean. +- **`@param name - description` documents meaning, not type.** For public callable APIs, include the tag and make it earn its place by describing role, constraint, ownership, valid range, or call-order semantics. +- **`@returns` documents _meaning_, never "void".** Drop the tag entirely for void returns; describe the side effect in the summary instead. Use `@returns` to clarify ownership ("a fresh array; caller owns it"), nullability semantics ("null when the glyph has no contours, not when it's missing"), or that the result is a snapshot vs a live reactive view. +- **Document side effects, lifetime, and reactivity.** TypeScript can't encode "runs after render", "mutates the Glyph signal", "JS-only — does not call NAPI", "transfers ownership of the buffer". That is exactly what JSDoc is for. +- **Stable terms over current implementation names.** Document the concept, not today's wiring. +- **No warnings, no scolding.** State the contract directly. +- **Do not document private helpers** unless they encode a non-obvious invariant. +- **Do not name current callers** ("used by FooManager") — rots fast. +- **Never use JSDoc as a TODO list.** That belongs in commits, issues, or `// TODO` comments. + +## What To Document + +Document where the type signature is silent. If the type fully encodes the contract, write nothing. Otherwise, prioritize these dimensions: + +- **Effects** — purity, mutation of arguments, mutation of shared state, I/O. +- **Ownership** — who owns the return value, who may mutate it, aliasing with internal state. +- **Identity vs value** — handles/refs/IDs that look like the loaded object but aren't; snapshots vs live views. +- **Nullability semantics** — what `null` / `undefined` / empty actually _means_ (absent, error, not-yet-loaded, end-of-stream). +- **Resolution semantics** — strict vs fallback, find vs find-or-create, exact vs nearest. +- **Lifetime and ordering** — preconditions, disposal, idempotence, what makes the result go stale. +- **Failure modes** — which errors, under which conditions; whether failure is observable or swallowed. +- **Concurrency and context** — thread, render phase, re-entrancy, async cancellation behavior. +- **Performance class** — Big-O, hot-path safety, sync-vs-async cost, when a convenient method is wrong. + +Pick only the dimensions that apply. Do not force every doc to address all of them. + +## Tag Reference Card (TS-first) + +| Tag | Use it when | Example | +| ------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `@param name - desc` | Every public param. Document the constraint, not the type. | `@param glyph - must be loaded (not a GlyphRef)` | +| `@returns desc` | Nullable / created / snapshot / read-only / non-obvious return. | `@returns null when no source is active; never throws.` | +| `@throws {Err} when …` | Every observable failure mode. Always type + condition. | `@throws {GlyphNotLoadedError} when called on a ref-only glyph.` | +| `@example` | Call order, setup, or output carries the lesson. | See below — fenced ts, imports, `// Output:` line. | +| `@remarks` | Long explanation that would bloat the summary. | One short paragraph; not multi-paragraph essays. | +| `@see {@link Foo}` | One tag per related sibling API. | `@see {@link createDraft}` | +| `@deprecated ` | Always with replacement or removal reason. | `@deprecated Use draft.setPositions instead — avoids NAPI per frame.` | +| `@template T - desc` | Generic with a _semantic_ constraint that isn't obvious from the signature. | `@template T - coordinate-space tag; controls bound interpretation.` | + +### Avoid in TypeScript + +These re-encode information TS already owns. Including them is noise and risks drift. + +- `@type`, `@typedef`, `@property` — TS variable annotations, `type`, and `interface` are the source of truth. +- `@class`, `@constructor`, `@extends`, `@implements`, `@function`, `@method` — the declaration shape says this. +- `{Type}` annotations inside `@param` / `@returns` — never write `@param {string} name`. Document meaning; TS owns the type. + +### Skip in app code + +These are doc-generator ceremony for published packages. Shift is not a published package; do not write these in app code. + +- `@since`, `@public`, `@beta`, `@alpha`, `@experimental`, `@category` +- `@author`, `@version`, `@copyright` +- date-fns-style `@name`/`@summary`/`@description` triples + +## Tag Format + +In TypeScript files, omit JSDoc type annotations. Let TypeScript own the type; let JSDoc own meaning. + +```ts +/** + * Snapshot of state required to redraw the scene layer. + * + * Building this frame establishes the reactive dependencies for the scene + * output. Drawing code consumes the frame as plain data. + * + * @param dependencies - Values that invalidate or describe one scene redraw. + */ +constructor(dependencies: SceneFrameDependencies) {} +``` + +For functions with multiple parameters, document each parameter by role: + +```ts +/** + * Converts a screen-space pointer into editor coordinate spaces. + * + * @param screen - Pointer position in canvas pixels. + * @param drawOffset - Glyph-local offset applied by the current editor view. + * @returns Coordinates in screen, scene, and glyph-local space. + */ +function resolveCoordinates( + screen: Point2D, + drawOffset: Point2D, +): Coordinates {} +``` + +When failure paths are observable, document them with `@throws`: + +```ts +/** + * Loads a glyph by handle. Resolves once the source is hydrated. + * + * @param handle - identity returned by {@link glyphHandleForUnicode}. + * @returns the loaded glyph; never a {@link GlyphRef}. + * @throws {GlyphNotFoundError} when the handle does not resolve in the active font. + * @see {@link glyphHandleForUnicode} + */ +async function loadGlyph(handle: GlyphHandle): Promise {} +``` + +When deprecating, name the replacement: + +```ts +/** + * @deprecated Use {@link draft.setPositions} — `bridge.setNodePositions` sends + * one NAPI call per point and causes ~450ms frames on dense glyphs. + */ +function setNodePositions(updates: NodePositionUpdate[]): void {} +``` + +## Examples — the rules + +Examples must be runnable assertions, not decoration. + +- **Always fenced and language-tagged** with ` ```ts `. VS Code highlights inside fences. +- **Self-contained.** Include imports. The reader should be able to paste the snippet and have it compile. +- **Show expected output** with a `// Output:` or `// =>` comment when the value carries the lesson. +- **Short.** 8–12 lines is the typical good length; 25 is the ceiling. If it doesn't fit, the example is the wrong shape. +- **One concept per `@example`.** Multiple `@example` blocks are fine and better than one mega-block. + +A good example for a Shift API: + +````ts +/** + * Begins a JS-only edit of the active glyph. Pair with {@link GlyphDraft.finish} + * to persist, or {@link GlyphDraft.discard} to revert. + * + * @returns a draft scoped to the active glyph; `null` when no glyph is loaded. + * + * @example + * ```ts + * const draft = editor.createDraft(); + * if (!draft) return; + * + * for (const update of dragFrame) { + * draft.setPositions(update); // JS-only; no NAPI + * } + * + * draft.finish("translate"); // syncs once, records undo + * ``` + */ +createDraft(): GlyphDraft | null {} +```` + +When TS inference is non-obvious, annotate the type position inline (Effect pattern): + +````ts +/** + * @example + * ```ts + * // ┌─── Option + * // ▼ + * const result = font.glyphForUnicode(0x41); + * ``` + */ +```` + +Avoid examples that depend on hidden setup, test fixtures, or implicit globals. + +## Overloads + +- **Per-overload JSDoc** when parameter _meanings_ differ. (This is the TS standard-library convention.) Copy the contract on each signature; do not put one block on the implementation signature. +- **Top-overload JSDoc only** when the contract is identical and only the type shape differs. The implementation signature stays bare. + +```ts +/** + * Resolves a glyph from its Unicode codepoint. + * @param codepoint - the Unicode scalar value. + */ +function glyphFor(codepoint: number): Glyph | null; +/** + * Resolves a glyph from its handle. + * @param handle - identity from a prior lookup; cheaper than codepoint resolution. + */ +function glyphFor(handle: GlyphHandle): Glyph | null; +function glyphFor(arg: number | GlyphHandle): Glyph | null { + /* impl */ +} +``` + +## Anti-Patterns (bad → good) + +### `@returns void` + +```ts +// ❌ +/** @returns void */ +clear(): void {} + +// ✅ describe the side effect; drop @returns entirely +/** Clears all queued render frames. Idempotent. */ +clear(): void {} +``` + +### Re-stating the signature in prose + +```ts +// ❌ +/** + * @param glyph - the glyph + * @param index - the index + */ + +// ✅ document the constraint +/** + * @param glyph - must be loaded (not a {@link GlyphRef}). + * @param index - zero-based contour index; -1 selects the outer hull. + */ +``` + +### Multi-paragraph summary + +```ts +// ❌ VS Code hover becomes a wall of text +/** + * This function is used to set positions. It is part of the GlyphDraft API and + * is used during drag operations. It does not call NAPI. It must be paired + * with either finish() or discard(). + */ + +// ✅ one-line contract + @remarks +/** + * Updates JS-side glyph positions; pair with {@link finish} or {@link discard}. + * + * @remarks + * JS-only — does not call NAPI. Use during drag hot path; call `finish()` once + * at gesture end to sync to Rust, or `discard()` to revert. + */ +``` + +### Naming current callers + +```ts +// ❌ rots fast +/** Called by GlyphSidebar and TransformPanel. */ + +// ✅ describe what it produces +/** Returns the active glyph's tight bounds, accounting for sidebearings. */ +``` + +### Bare `@deprecated` + +```ts +// ❌ +/** @deprecated */ +function oldThing() {} + +// ✅ name the replacement or the reason +/** @deprecated Use {@link newThing} — removes the legacy 2D-only path. */ +function oldThing() {} +``` + +### `@example` for trivial calls + +````ts +// ❌ noise +/** + * @example + * ```ts + * const id = glyph.id; + * ``` + */ +get id(): string {} + +// ✅ no @example; the name is the whole story +get id(): string {} +```` + +### Examples that depend on hidden setup + +````ts +// ❌ what is `editor`? +/** @example editor.commit(); */ + +// ✅ self-contained +/** + * @example + * ```ts + * const editor = createTestEditor(); + * await editor.loadGlyph("A"); + * editor.commit(); + * ``` + */ +```` + +### General "do not" list + +- API dumps covering every accessor. +- Long examples that obscure the method being documented. +- Compat wrappers or aliases that hide which API should be used. +- Rewriting behavior while documenting unless the user requested the API fix too. +- Describing bugs, migrations, or "currently used by X" in API docs. +- Listing concrete state variants as examples when the actual contract is broader. +- Documenting private helpers with full `@param`/`@returns`/`@example` blocks — a one-line summary is enough. +- Mixing `{Type}`-style JSDoc with TSDoc tags in the same module; pick one (in TS, drop `{Type}`). + +## Quick Checklist + +Before saving, scan your doc against this list: + +- [ ] First line is one sentence, verb-phrase, ends with a period. +- [ ] No `@type`, `@typedef`, or `{Type}` annotations. +- [ ] No `@returns void`. Side effect described in summary. +- [ ] Every observable failure has `@throws {Type} when …`. +- [ ] `@example` (if present) has imports, is fenced ` ```ts `, and shows output when it carries the lesson. +- [ ] No current-caller name-drops, no migration notes, no TODOs. +- [ ] No ceremony tags (`@since`, `@public`, `@category`, `@author`). +- [ ] If deprecated, the replacement or reason is named. diff --git a/.claude/settings.json b/.claude/settings.json index 562606d3..856f69d2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "git diff --name-only --diff-filter=ACMR HEAD | grep -E '\\.(ts|tsx|json|md|css)$' | xargs pnpm prettier --write --ignore-unknown 2>/dev/null; true" + "command": "git diff --name-only --diff-filter=ACMR HEAD | grep -E '\\.(ts|tsx|json|md|css)$' | xargs pnpm oxfmt 2>/dev/null; true" } ] } diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index 89bcdb4a..5a613103 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -48,9 +48,10 @@ This is the most valuable section because it captures knowledge that is invisibl Each invariant states a rule and explains why it exists: -> **Architecture Invariant:** Rust is never touched during the draft hot path. `GlyphDraft.setPositions` calls only `glyph.apply()` — a JS-only signal update. Rust sees the final result once when `finish()` calls `bridge.sync()`. This exists because NAPI struct marshaling at thousands of points per frame causes ~450ms frames + GC pressure. +> **Architecture Invariant:** Rust is never touched during the draft preview hot path. `SourceEditDraft.previewPositionPatch()` applies a sparse patch to local reactive geometry only. Rust sees the final sparse patch once when `commit()` calls `GlyphSource.commitPositionPatch()`. This exists because round-tripping full glyph values for thousands of points per frame causes long frames and GC pressure. Good invariants describe: + - What never happens and why ("X never imports Y because Z") - Performance-motivated design choices ("uses flat arrays, never JSON, because Y") - Semantic distinctions invisible in types ("`$glyph` fires on identity changes, not data changes") diff --git a/.claude/skills/jsdoc/SKILL.md b/.claude/skills/jsdoc/SKILL.md new file mode 100644 index 00000000..5521445d --- /dev/null +++ b/.claude/skills/jsdoc/SKILL.md @@ -0,0 +1,343 @@ +--- +name: jsdoc +description: Add or revise source-level JSDoc for Shift APIs. Use this skill before writing or editing documentation comments for exported classes, methods, constructors, domain data structures, render frames, reactive state, or any API where caller intent, side effects, lifetime, ownership, or nullability are easy to misunderstand. +--- + +# /jsdoc — Source API Contracts + +Write JSDoc as the stable public contract a caller needs without reading the implementation. + +JSDoc comments must sit immediately before the documented symbol and use `/** ... */` so tooling can parse them. Follow the standard tag vocabulary from , adapted for TypeScript source. + +## Why this matters + +- **VS Code hover** renders the first line in bold and the rest as body. A one-sentence contract on line 1 is the single highest-leverage thing you can write. +- **TypeScript already encodes shape.** JSDoc is for what types can't say: ownership, lifetime, mutation, side effects, side-channel reactivity, nullability semantics, performance class, call ordering. +- **Code review** is the second reader. Reviewers should be able to judge a call site against the doc without opening the implementation. + +## Runbook + +1. Identify the audience: caller, implementer, renderer, tool author, or maintainer. +2. State the stable contract in one short opening sentence. +3. Add details only for ownership, lifetime, reactivity, mutation, side effects, nullability, performance, or call ordering. Put long detail under `@remarks`. +4. Use tags for callable APIs: + - Add `@param` for every public constructor, method, or function parameter, and make the text describe the parameter's role, constraint, ownership, or valid range. + - Add `@returns` when a method returns a value, nullable result, created object, snapshot, or read-only view. + - Add `@throws {ErrorType} when …` for every observable failure mode (custom error class, semantic Error). + - Do not add `@returns void`; describe the side effect in prose instead. +5. Cross-reference siblings with `@see {@link OtherSymbol}` (one tag per related symbol). +6. Add `@example` only when the intended call flow, ordering, or output is not obvious from the signature. +7. Re-read the comment and delete implementation trivia, current call-site anecdotes, and unstable examples. + +## Hard Style Rules + +- **One-line contract.** First sentence is a verb phrase (`Returns…`, `Applies…`, `Triggers…`), ending with a period. No "This function…". +- **`@remarks` for the long explanation.** If you need more than one sentence of context, demote it under `@remarks` so the hover summary stays clean. +- **`@param name - description` documents meaning, not type.** For public callable APIs, include the tag and make it earn its place by describing role, constraint, ownership, valid range, or call-order semantics. +- **`@returns` documents _meaning_, never "void".** Drop the tag entirely for void returns; describe the side effect in the summary instead. Use `@returns` to clarify ownership ("a fresh array; caller owns it"), nullability semantics ("null when the glyph has no contours, not when it's missing"), or that the result is a snapshot vs a live reactive view. +- **Document side effects, lifetime, and reactivity.** TypeScript can't encode "runs after render", "mutates the Glyph signal", "JS-only — does not call NAPI", "transfers ownership of the buffer". That is exactly what JSDoc is for. +- **Stable terms over current implementation names.** Document the concept, not today's wiring. +- **No warnings, no scolding.** State the contract directly. +- **Do not document private helpers** unless they encode a non-obvious invariant. +- **Do not name current callers** ("used by FooManager") — rots fast. +- **Never use JSDoc as a TODO list.** That belongs in commits, issues, or `// TODO` comments. + +## What To Document + +Document where the type signature is silent. If the type fully encodes the contract, write nothing. Otherwise, prioritize these dimensions: + +- **Effects** — purity, mutation of arguments, mutation of shared state, I/O. +- **Ownership** — who owns the return value, who may mutate it, aliasing with internal state. +- **Identity vs value** — handles/refs/IDs that look like the loaded object but aren't; snapshots vs live views. +- **Nullability semantics** — what `null` / `undefined` / empty actually _means_ (absent, error, not-yet-loaded, end-of-stream). +- **Resolution semantics** — strict vs fallback, find vs find-or-create, exact vs nearest. +- **Lifetime and ordering** — preconditions, disposal, idempotence, what makes the result go stale. +- **Failure modes** — which errors, under which conditions; whether failure is observable or swallowed. +- **Concurrency and context** — thread, render phase, re-entrancy, async cancellation behavior. +- **Performance class** — Big-O, hot-path safety, sync-vs-async cost, when a convenient method is wrong. + +Pick only the dimensions that apply. Do not force every doc to address all of them. + +## Tag Reference Card (TS-first) + +| Tag | Use it when | Example | +| ------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `@param name - desc` | Every public param. Document the constraint, not the type. | `@param glyph - must be loaded (not a GlyphRef)` | +| `@returns desc` | Nullable / created / snapshot / read-only / non-obvious return. | `@returns null when no source is active; never throws.` | +| `@throws {Err} when …` | Every observable failure mode. Always type + condition. | `@throws {GlyphNotLoadedError} when called on a ref-only glyph.` | +| `@example` | Call order, setup, or output carries the lesson. | See below — fenced ts, imports, `// Output:` line. | +| `@remarks` | Long explanation that would bloat the summary. | One short paragraph; not multi-paragraph essays. | +| `@see {@link Foo}` | One tag per related sibling API. | `@see {@link createDraft}` | +| `@deprecated ` | Always with replacement or removal reason. | `@deprecated Use draft.setPositions instead — avoids NAPI per frame.` | +| `@template T - desc` | Generic with a _semantic_ constraint that isn't obvious from the signature. | `@template T - coordinate-space tag; controls bound interpretation.` | + +### Avoid in TypeScript + +These re-encode information TS already owns. Including them is noise and risks drift. + +- `@type`, `@typedef`, `@property` — TS variable annotations, `type`, and `interface` are the source of truth. +- `@class`, `@constructor`, `@extends`, `@implements`, `@function`, `@method` — the declaration shape says this. +- `{Type}` annotations inside `@param` / `@returns` — never write `@param {string} name`. Document meaning; TS owns the type. + +### Skip in app code + +These are doc-generator ceremony for published packages. Shift is not a published package; do not write these in app code. + +- `@since`, `@public`, `@beta`, `@alpha`, `@experimental`, `@category` +- `@author`, `@version`, `@copyright` +- date-fns-style `@name`/`@summary`/`@description` triples + +## Tag Format + +In TypeScript files, omit JSDoc type annotations. Let TypeScript own the type; let JSDoc own meaning. + +```ts +/** + * Snapshot of state required to redraw the scene layer. + * + * Building this frame establishes the reactive dependencies for the scene + * output. Drawing code consumes the frame as plain data. + * + * @param dependencies - Values that invalidate or describe one scene redraw. + */ +constructor(dependencies: SceneFrameDependencies) {} +``` + +For functions with multiple parameters, document each parameter by role: + +```ts +/** + * Converts a screen-space pointer into editor coordinate spaces. + * + * @param screen - Pointer position in canvas pixels. + * @param drawOffset - Glyph-local offset applied by the current editor view. + * @returns Coordinates in screen, scene, and glyph-local space. + */ +function resolveCoordinates( + screen: Point2D, + drawOffset: Point2D, +): Coordinates {} +``` + +When failure paths are observable, document them with `@throws`: + +```ts +/** + * Loads a glyph by handle. Resolves once the source is hydrated. + * + * @param handle - identity returned by {@link glyphHandleForUnicode}. + * @returns the loaded glyph; never a {@link GlyphRef}. + * @throws {GlyphNotFoundError} when the handle does not resolve in the active font. + * @see {@link glyphHandleForUnicode} + */ +async function loadGlyph(handle: GlyphHandle): Promise {} +``` + +When deprecating, name the replacement: + +```ts +/** + * @deprecated Use {@link draft.setPositions} — `bridge.setNodePositions` sends + * one NAPI call per point and causes ~450ms frames on dense glyphs. + */ +function setNodePositions(updates: NodePositionUpdate[]): void {} +``` + +## Examples — the rules + +Examples must be runnable assertions, not decoration. + +- **Always fenced and language-tagged** with ` ```ts `. VS Code highlights inside fences. +- **Self-contained.** Include imports. The reader should be able to paste the snippet and have it compile. +- **Show expected output** with a `// Output:` or `// =>` comment when the value carries the lesson. +- **Short.** 8–12 lines is the typical good length; 25 is the ceiling. If it doesn't fit, the example is the wrong shape. +- **One concept per `@example`.** Multiple `@example` blocks are fine and better than one mega-block. + +A good example for a Shift API: + +````ts +/** + * Begins a JS-only edit of the active glyph. Pair with {@link GlyphDraft.finish} + * to persist, or {@link GlyphDraft.discard} to revert. + * + * @returns a draft scoped to the active glyph; `null` when no glyph is loaded. + * + * @example + * ```ts + * const draft = editor.createDraft(); + * if (!draft) return; + * + * for (const update of dragFrame) { + * draft.setPositions(update); // JS-only; no NAPI + * } + * + * draft.finish("translate"); // syncs once, records undo + * ``` + */ +createDraft(): GlyphDraft | null {} +```` + +When TS inference is non-obvious, annotate the type position inline (Effect pattern): + +````ts +/** + * @example + * ```ts + * // ┌─── Option + * // ▼ + * const result = font.glyphForUnicode(0x41); + * ``` + */ +```` + +Avoid examples that depend on hidden setup, test fixtures, or implicit globals. + +## Overloads + +- **Per-overload JSDoc** when parameter _meanings_ differ. (This is the TS standard-library convention.) Copy the contract on each signature; do not put one block on the implementation signature. +- **Top-overload JSDoc only** when the contract is identical and only the type shape differs. The implementation signature stays bare. + +```ts +/** + * Resolves a glyph from its Unicode codepoint. + * @param codepoint - the Unicode scalar value. + */ +function glyphFor(codepoint: number): Glyph | null; +/** + * Resolves a glyph from its handle. + * @param handle - identity from a prior lookup; cheaper than codepoint resolution. + */ +function glyphFor(handle: GlyphHandle): Glyph | null; +function glyphFor(arg: number | GlyphHandle): Glyph | null { + /* impl */ +} +``` + +## Anti-Patterns (bad → good) + +### `@returns void` + +```ts +// ❌ +/** @returns void */ +clear(): void {} + +// ✅ describe the side effect; drop @returns entirely +/** Clears all queued render frames. Idempotent. */ +clear(): void {} +``` + +### Re-stating the signature in prose + +```ts +// ❌ +/** + * @param glyph - the glyph + * @param index - the index + */ + +// ✅ document the constraint +/** + * @param glyph - must be loaded (not a {@link GlyphRef}). + * @param index - zero-based contour index; -1 selects the outer hull. + */ +``` + +### Multi-paragraph summary + +```ts +// ❌ VS Code hover becomes a wall of text +/** + * This function is used to set positions. It is part of the GlyphDraft API and + * is used during drag operations. It does not call NAPI. It must be paired + * with either finish() or discard(). + */ + +// ✅ one-line contract + @remarks +/** + * Updates JS-side glyph positions; pair with {@link finish} or {@link discard}. + * + * @remarks + * JS-only — does not call NAPI. Use during drag hot path; call `finish()` once + * at gesture end to sync to Rust, or `discard()` to revert. + */ +``` + +### Naming current callers + +```ts +// ❌ rots fast +/** Called by GlyphSidebar and TransformPanel. */ + +// ✅ describe what it produces +/** Returns the active glyph's tight bounds, accounting for sidebearings. */ +``` + +### Bare `@deprecated` + +```ts +// ❌ +/** @deprecated */ +function oldThing() {} + +// ✅ name the replacement or the reason +/** @deprecated Use {@link newThing} — removes the legacy 2D-only path. */ +function oldThing() {} +``` + +### `@example` for trivial calls + +````ts +// ❌ noise +/** + * @example + * ```ts + * const id = glyph.id; + * ``` + */ +get id(): string {} + +// ✅ no @example; the name is the whole story +get id(): string {} +```` + +### Examples that depend on hidden setup + +````ts +// ❌ what is `editor`? +/** @example editor.commit(); */ + +// ✅ self-contained +/** + * @example + * ```ts + * const editor = createTestEditor(); + * await editor.loadGlyph("A"); + * editor.commit(); + * ``` + */ +```` + +### General "do not" list + +- API dumps covering every accessor. +- Long examples that obscure the method being documented. +- Compat wrappers or aliases that hide which API should be used. +- Rewriting behavior while documenting unless the user requested the API fix too. +- Describing bugs, migrations, or "currently used by X" in API docs. +- Listing concrete state variants as examples when the actual contract is broader. +- Documenting private helpers with full `@param`/`@returns`/`@example` blocks — a one-line summary is enough. +- Mixing `{Type}`-style JSDoc with TSDoc tags in the same module; pick one (in TS, drop `{Type}`). + +## Quick Checklist + +Before saving, scan your doc against this list: + +- [ ] First line is one sentence, verb-phrase, ends with a period. +- [ ] No `@type`, `@typedef`, or `{Type}` annotations. +- [ ] No `@returns void`. Side effect described in summary. +- [ ] Every observable failure has `@throws {Type} when …`. +- [ ] `@example` (if present) has imports, is fenced ` ```ts `, and shows output when it carries the lesson. +- [ ] No current-caller name-drops, no migration notes, no TODOs. +- [ ] No ceremony tags (`@since`, `@public`, `@category`, `@author`). +- [ ] If deprecated, the replacement or reason is named. diff --git a/.claude/skills/writing-tests/SKILL.md b/.claude/skills/writing-tests/SKILL.md index da89a9cc..16419079 100644 --- a/.claude/skills/writing-tests/SKILL.md +++ b/.claude/skills/writing-tests/SKILL.md @@ -147,6 +147,12 @@ Before committing a test, run through these. Any miss means the test is wrong. - No wrapper factories around `TestEditor`. If you need a reusable helper, it belongs as a method on `TestEditor` itself (see `pointerMove`, `click`, `escape`). - If your test needs a pre-drawn shape, draw it with the pen/shape tool in `beforeEach` — don't construct glyph snapshots inline. +## Naming + +- `describe()` names should explain the behavior or contract under test, not just repeat a class or file name. +- Prefer `describe("glyph source geometry follows coordinate patches", ...)` over `describe("GlyphSourceState", ...)`. +- A class name can appear inside a larger phrase when it adds clarity, but the phrase should still tell the reader what invariant is being protected. + ## When testing is genuinely hard Some code resists clean unit testing — DOM event handlers, focus management, IME composition, React effect lifecycle. diff --git a/.codex/skills/jsdoc/SKILL.md b/.codex/skills/jsdoc/SKILL.md new file mode 100644 index 00000000..5521445d --- /dev/null +++ b/.codex/skills/jsdoc/SKILL.md @@ -0,0 +1,343 @@ +--- +name: jsdoc +description: Add or revise source-level JSDoc for Shift APIs. Use this skill before writing or editing documentation comments for exported classes, methods, constructors, domain data structures, render frames, reactive state, or any API where caller intent, side effects, lifetime, ownership, or nullability are easy to misunderstand. +--- + +# /jsdoc — Source API Contracts + +Write JSDoc as the stable public contract a caller needs without reading the implementation. + +JSDoc comments must sit immediately before the documented symbol and use `/** ... */` so tooling can parse them. Follow the standard tag vocabulary from , adapted for TypeScript source. + +## Why this matters + +- **VS Code hover** renders the first line in bold and the rest as body. A one-sentence contract on line 1 is the single highest-leverage thing you can write. +- **TypeScript already encodes shape.** JSDoc is for what types can't say: ownership, lifetime, mutation, side effects, side-channel reactivity, nullability semantics, performance class, call ordering. +- **Code review** is the second reader. Reviewers should be able to judge a call site against the doc without opening the implementation. + +## Runbook + +1. Identify the audience: caller, implementer, renderer, tool author, or maintainer. +2. State the stable contract in one short opening sentence. +3. Add details only for ownership, lifetime, reactivity, mutation, side effects, nullability, performance, or call ordering. Put long detail under `@remarks`. +4. Use tags for callable APIs: + - Add `@param` for every public constructor, method, or function parameter, and make the text describe the parameter's role, constraint, ownership, or valid range. + - Add `@returns` when a method returns a value, nullable result, created object, snapshot, or read-only view. + - Add `@throws {ErrorType} when …` for every observable failure mode (custom error class, semantic Error). + - Do not add `@returns void`; describe the side effect in prose instead. +5. Cross-reference siblings with `@see {@link OtherSymbol}` (one tag per related symbol). +6. Add `@example` only when the intended call flow, ordering, or output is not obvious from the signature. +7. Re-read the comment and delete implementation trivia, current call-site anecdotes, and unstable examples. + +## Hard Style Rules + +- **One-line contract.** First sentence is a verb phrase (`Returns…`, `Applies…`, `Triggers…`), ending with a period. No "This function…". +- **`@remarks` for the long explanation.** If you need more than one sentence of context, demote it under `@remarks` so the hover summary stays clean. +- **`@param name - description` documents meaning, not type.** For public callable APIs, include the tag and make it earn its place by describing role, constraint, ownership, valid range, or call-order semantics. +- **`@returns` documents _meaning_, never "void".** Drop the tag entirely for void returns; describe the side effect in the summary instead. Use `@returns` to clarify ownership ("a fresh array; caller owns it"), nullability semantics ("null when the glyph has no contours, not when it's missing"), or that the result is a snapshot vs a live reactive view. +- **Document side effects, lifetime, and reactivity.** TypeScript can't encode "runs after render", "mutates the Glyph signal", "JS-only — does not call NAPI", "transfers ownership of the buffer". That is exactly what JSDoc is for. +- **Stable terms over current implementation names.** Document the concept, not today's wiring. +- **No warnings, no scolding.** State the contract directly. +- **Do not document private helpers** unless they encode a non-obvious invariant. +- **Do not name current callers** ("used by FooManager") — rots fast. +- **Never use JSDoc as a TODO list.** That belongs in commits, issues, or `// TODO` comments. + +## What To Document + +Document where the type signature is silent. If the type fully encodes the contract, write nothing. Otherwise, prioritize these dimensions: + +- **Effects** — purity, mutation of arguments, mutation of shared state, I/O. +- **Ownership** — who owns the return value, who may mutate it, aliasing with internal state. +- **Identity vs value** — handles/refs/IDs that look like the loaded object but aren't; snapshots vs live views. +- **Nullability semantics** — what `null` / `undefined` / empty actually _means_ (absent, error, not-yet-loaded, end-of-stream). +- **Resolution semantics** — strict vs fallback, find vs find-or-create, exact vs nearest. +- **Lifetime and ordering** — preconditions, disposal, idempotence, what makes the result go stale. +- **Failure modes** — which errors, under which conditions; whether failure is observable or swallowed. +- **Concurrency and context** — thread, render phase, re-entrancy, async cancellation behavior. +- **Performance class** — Big-O, hot-path safety, sync-vs-async cost, when a convenient method is wrong. + +Pick only the dimensions that apply. Do not force every doc to address all of them. + +## Tag Reference Card (TS-first) + +| Tag | Use it when | Example | +| ------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `@param name - desc` | Every public param. Document the constraint, not the type. | `@param glyph - must be loaded (not a GlyphRef)` | +| `@returns desc` | Nullable / created / snapshot / read-only / non-obvious return. | `@returns null when no source is active; never throws.` | +| `@throws {Err} when …` | Every observable failure mode. Always type + condition. | `@throws {GlyphNotLoadedError} when called on a ref-only glyph.` | +| `@example` | Call order, setup, or output carries the lesson. | See below — fenced ts, imports, `// Output:` line. | +| `@remarks` | Long explanation that would bloat the summary. | One short paragraph; not multi-paragraph essays. | +| `@see {@link Foo}` | One tag per related sibling API. | `@see {@link createDraft}` | +| `@deprecated ` | Always with replacement or removal reason. | `@deprecated Use draft.setPositions instead — avoids NAPI per frame.` | +| `@template T - desc` | Generic with a _semantic_ constraint that isn't obvious from the signature. | `@template T - coordinate-space tag; controls bound interpretation.` | + +### Avoid in TypeScript + +These re-encode information TS already owns. Including them is noise and risks drift. + +- `@type`, `@typedef`, `@property` — TS variable annotations, `type`, and `interface` are the source of truth. +- `@class`, `@constructor`, `@extends`, `@implements`, `@function`, `@method` — the declaration shape says this. +- `{Type}` annotations inside `@param` / `@returns` — never write `@param {string} name`. Document meaning; TS owns the type. + +### Skip in app code + +These are doc-generator ceremony for published packages. Shift is not a published package; do not write these in app code. + +- `@since`, `@public`, `@beta`, `@alpha`, `@experimental`, `@category` +- `@author`, `@version`, `@copyright` +- date-fns-style `@name`/`@summary`/`@description` triples + +## Tag Format + +In TypeScript files, omit JSDoc type annotations. Let TypeScript own the type; let JSDoc own meaning. + +```ts +/** + * Snapshot of state required to redraw the scene layer. + * + * Building this frame establishes the reactive dependencies for the scene + * output. Drawing code consumes the frame as plain data. + * + * @param dependencies - Values that invalidate or describe one scene redraw. + */ +constructor(dependencies: SceneFrameDependencies) {} +``` + +For functions with multiple parameters, document each parameter by role: + +```ts +/** + * Converts a screen-space pointer into editor coordinate spaces. + * + * @param screen - Pointer position in canvas pixels. + * @param drawOffset - Glyph-local offset applied by the current editor view. + * @returns Coordinates in screen, scene, and glyph-local space. + */ +function resolveCoordinates( + screen: Point2D, + drawOffset: Point2D, +): Coordinates {} +``` + +When failure paths are observable, document them with `@throws`: + +```ts +/** + * Loads a glyph by handle. Resolves once the source is hydrated. + * + * @param handle - identity returned by {@link glyphHandleForUnicode}. + * @returns the loaded glyph; never a {@link GlyphRef}. + * @throws {GlyphNotFoundError} when the handle does not resolve in the active font. + * @see {@link glyphHandleForUnicode} + */ +async function loadGlyph(handle: GlyphHandle): Promise {} +``` + +When deprecating, name the replacement: + +```ts +/** + * @deprecated Use {@link draft.setPositions} — `bridge.setNodePositions` sends + * one NAPI call per point and causes ~450ms frames on dense glyphs. + */ +function setNodePositions(updates: NodePositionUpdate[]): void {} +``` + +## Examples — the rules + +Examples must be runnable assertions, not decoration. + +- **Always fenced and language-tagged** with ` ```ts `. VS Code highlights inside fences. +- **Self-contained.** Include imports. The reader should be able to paste the snippet and have it compile. +- **Show expected output** with a `// Output:` or `// =>` comment when the value carries the lesson. +- **Short.** 8–12 lines is the typical good length; 25 is the ceiling. If it doesn't fit, the example is the wrong shape. +- **One concept per `@example`.** Multiple `@example` blocks are fine and better than one mega-block. + +A good example for a Shift API: + +````ts +/** + * Begins a JS-only edit of the active glyph. Pair with {@link GlyphDraft.finish} + * to persist, or {@link GlyphDraft.discard} to revert. + * + * @returns a draft scoped to the active glyph; `null` when no glyph is loaded. + * + * @example + * ```ts + * const draft = editor.createDraft(); + * if (!draft) return; + * + * for (const update of dragFrame) { + * draft.setPositions(update); // JS-only; no NAPI + * } + * + * draft.finish("translate"); // syncs once, records undo + * ``` + */ +createDraft(): GlyphDraft | null {} +```` + +When TS inference is non-obvious, annotate the type position inline (Effect pattern): + +````ts +/** + * @example + * ```ts + * // ┌─── Option + * // ▼ + * const result = font.glyphForUnicode(0x41); + * ``` + */ +```` + +Avoid examples that depend on hidden setup, test fixtures, or implicit globals. + +## Overloads + +- **Per-overload JSDoc** when parameter _meanings_ differ. (This is the TS standard-library convention.) Copy the contract on each signature; do not put one block on the implementation signature. +- **Top-overload JSDoc only** when the contract is identical and only the type shape differs. The implementation signature stays bare. + +```ts +/** + * Resolves a glyph from its Unicode codepoint. + * @param codepoint - the Unicode scalar value. + */ +function glyphFor(codepoint: number): Glyph | null; +/** + * Resolves a glyph from its handle. + * @param handle - identity from a prior lookup; cheaper than codepoint resolution. + */ +function glyphFor(handle: GlyphHandle): Glyph | null; +function glyphFor(arg: number | GlyphHandle): Glyph | null { + /* impl */ +} +``` + +## Anti-Patterns (bad → good) + +### `@returns void` + +```ts +// ❌ +/** @returns void */ +clear(): void {} + +// ✅ describe the side effect; drop @returns entirely +/** Clears all queued render frames. Idempotent. */ +clear(): void {} +``` + +### Re-stating the signature in prose + +```ts +// ❌ +/** + * @param glyph - the glyph + * @param index - the index + */ + +// ✅ document the constraint +/** + * @param glyph - must be loaded (not a {@link GlyphRef}). + * @param index - zero-based contour index; -1 selects the outer hull. + */ +``` + +### Multi-paragraph summary + +```ts +// ❌ VS Code hover becomes a wall of text +/** + * This function is used to set positions. It is part of the GlyphDraft API and + * is used during drag operations. It does not call NAPI. It must be paired + * with either finish() or discard(). + */ + +// ✅ one-line contract + @remarks +/** + * Updates JS-side glyph positions; pair with {@link finish} or {@link discard}. + * + * @remarks + * JS-only — does not call NAPI. Use during drag hot path; call `finish()` once + * at gesture end to sync to Rust, or `discard()` to revert. + */ +``` + +### Naming current callers + +```ts +// ❌ rots fast +/** Called by GlyphSidebar and TransformPanel. */ + +// ✅ describe what it produces +/** Returns the active glyph's tight bounds, accounting for sidebearings. */ +``` + +### Bare `@deprecated` + +```ts +// ❌ +/** @deprecated */ +function oldThing() {} + +// ✅ name the replacement or the reason +/** @deprecated Use {@link newThing} — removes the legacy 2D-only path. */ +function oldThing() {} +``` + +### `@example` for trivial calls + +````ts +// ❌ noise +/** + * @example + * ```ts + * const id = glyph.id; + * ``` + */ +get id(): string {} + +// ✅ no @example; the name is the whole story +get id(): string {} +```` + +### Examples that depend on hidden setup + +````ts +// ❌ what is `editor`? +/** @example editor.commit(); */ + +// ✅ self-contained +/** + * @example + * ```ts + * const editor = createTestEditor(); + * await editor.loadGlyph("A"); + * editor.commit(); + * ``` + */ +```` + +### General "do not" list + +- API dumps covering every accessor. +- Long examples that obscure the method being documented. +- Compat wrappers or aliases that hide which API should be used. +- Rewriting behavior while documenting unless the user requested the API fix too. +- Describing bugs, migrations, or "currently used by X" in API docs. +- Listing concrete state variants as examples when the actual contract is broader. +- Documenting private helpers with full `@param`/`@returns`/`@example` blocks — a one-line summary is enough. +- Mixing `{Type}`-style JSDoc with TSDoc tags in the same module; pick one (in TS, drop `{Type}`). + +## Quick Checklist + +Before saving, scan your doc against this list: + +- [ ] First line is one sentence, verb-phrase, ends with a period. +- [ ] No `@type`, `@typedef`, or `{Type}` annotations. +- [ ] No `@returns void`. Side effect described in summary. +- [ ] Every observable failure has `@throws {Type} when …`. +- [ ] `@example` (if present) has imports, is fenced ` ```ts `, and shows output when it carries the lesson. +- [ ] No current-caller name-drops, no migration notes, no TODOs. +- [ ] No ceremony tags (`@since`, `@public`, `@category`, `@author`). +- [ ] If deprecated, the replacement or reason is named. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b6a839a1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.{rs,toml}] +indent_size = 4 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..ec702262 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,28 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "sortPackageJson": false, + "ignorePatterns": [ + "node_modules", + "dist", + "out", + ".turbo", + "*.min.js", + "*.min.css", + "pnpm-lock.yaml", + "packages/types/src/generated", + "packages/types/src/bridge/generated.ts", + "packages/types/__fixtures__", + "crates/shift-bridge/index.js", + "crates/shift-bridge/index.d.ts", + "**/vendor", + "packages/glyph-info/resources" + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db806361..bd331858 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,7 @@ repos: entry: pnpm prettier --write --ignore-unknown language: system types_or: [javascript, jsx, ts, tsx, css, json, markdown] + exclude: '^(packages/types/src/bridge/generated\.ts|packages/types/__fixtures__/|crates/shift-bridge/index\.(d\.ts|js))' - id: oxlint name: oxlint diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 50329e61..00000000 --- a/.prettierignore +++ /dev/null @@ -1,13 +0,0 @@ -node_modules -dist -out -.turbo -*.min.js -*.min.css -pnpm-lock.yaml -packages/types/src/generated -packages/types/src/bridge/generated.ts -packages/types/__fixtures__ -crates/shift-bridge/index.js -crates/shift-bridge/index.d.ts -**/vendor diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index edcafc22..00000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "useTabs": false, - "trailingComma": "all", - "printWidth": 100, - "bracketSpacing": true, - "arrowParens": "always" -} diff --git a/apps/desktop/.oxlintrc.json b/apps/desktop/.oxlintrc.json index e312cbbf..29b905cb 100644 --- a/apps/desktop/.oxlintrc.json +++ b/apps/desktop/.oxlintrc.json @@ -1,5 +1,8 @@ { - "jsPlugins": ["oxlint-plugin-eslint", "../../scripts/oxlint/shift-plugin.mjs"], + "jsPlugins": [ + "oxlint-plugin-eslint", + "../../scripts/oxlint/shift-plugin.mjs" + ], "rules": { "eslint/no-unused-expressions": "off", "typescript/no-useless-empty-export": "off", @@ -33,10 +36,10 @@ "message": "Do not use export *. Use named exports to keep the public API explicit." } ], - "shift/no-raw-contour-access": "error", "shift/prefer-instance-method-on-glyph": "error", "shift/no-raw-segment-parse": "error", "shift/no-get-signal-value-method": "error", + "shift/no-reactive-value-outside-boundary": "error", "shift/no-unbranded-ids": "error", "shift/no-snapshot-in-domain": "error", "shift/no-raw-point-type-check": "error", diff --git a/apps/desktop/e2e/perf.spec.ts b/apps/desktop/e2e/perf.spec.ts index 049c22b1..b861a443 100644 --- a/apps/desktop/e2e/perf.spec.ts +++ b/apps/desktop/e2e/perf.spec.ts @@ -137,7 +137,10 @@ test.describe("Performance — 50K points", () => { const base = baseline[stats.label]; // For near-zero baselines (sub-millisecond ops), allow up to 1ms absolute // jitter instead of a percentage — 0.00ms → 0.10ms isn't a real regression. - const allowed = Math.max(base.p95 * (1 + REGRESSION_TOLERANCE), base.p95 + 1); + const allowed = Math.max( + base.p95 * (1 + REGRESSION_TOLERANCE), + base.p95 + 1, + ); expect( stats.p95, @@ -150,16 +153,17 @@ test.describe("Performance — 50K points", () => { const pointCount = await page.evaluate((data) => { const shift = (window as any).__shift; - if (!shift) throw new Error("__shift not exposed — was the app built with __PLAYWRIGHT__?"); + if (!shift) + throw new Error( + "__shift not exposed — was the app built with __PLAYWRIGHT__?", + ); const editor = shift.getEditor(); const result = editor.bridge.pasteContours(data, 0, 0); if (!result.success) throw new Error("pasteContours failed"); - return editor.bridge - .getEditingSnapshot() - ?.contours.reduce((sum: number, c: any) => sum + c.points.length, 0); + return editor.editGlyphSource?.allPoints.length ?? 0; }, contours); expect(pointCount).toBeGreaterThanOrEqual(TARGET_POINTS); @@ -174,25 +178,29 @@ test.describe("Performance — 50K points", () => { const editor = (window as any).__shift.getEditor(); editor.bridge.pasteContours(contours, 0, 0); - const snapshot = editor.bridge.getEditingSnapshot(); - const pointIds = snapshot.contours[0].points.slice(0, 5).map((p: any) => p.id); - editor.selection.select(pointIds.map((id: string) => ({ kind: "point", id }))); + const pointIds = editor.editGlyphSource.allPoints + .slice(0, 5) + .map((p: any) => p.id); + editor.selection.select( + pointIds.map((id: string) => ({ kind: "point", id })), + ); - const draft = editor.createDraft(); + const draft = editor.beginSourceEditDraft({ points: pointIds }); const times: number[] = []; for (let i = 0; i < frames; i++) { const start = performance.now(); const updates = pointIds.map((id: string, idx: number) => ({ - node: { kind: "point" as const, id }, + kind: "point" as const, + id, x: 100 + i + idx, y: 200 + i + idx, })); - draft.setPositions(updates); + draft.previewPositionPatch(updates); times.push(performance.now() - start); } - draft.finish("translate-few"); + draft.commit("translate-few"); return times; }, { contours, frames: DRAG_FRAMES }, @@ -211,26 +219,28 @@ test.describe("Performance — 50K points", () => { const editor = (window as any).__shift.getEditor(); editor.bridge.pasteContours(contours, 0, 0); - const snapshot = editor.bridge.getEditingSnapshot(); - const allPoints = snapshot.contours.flatMap((c: any) => c.points); + const allPoints = editor.editGlyphSource.allPoints; const pointIds = allPoints.slice(0, 1000).map((p: any) => p.id); - editor.selection.select(pointIds.map((id: string) => ({ kind: "point", id }))); + editor.selection.select( + pointIds.map((id: string) => ({ kind: "point", id })), + ); - const draft = editor.createDraft(); + const draft = editor.beginSourceEditDraft({ points: pointIds }); const times: number[] = []; for (let i = 0; i < frames; i++) { const start = performance.now(); const updates = pointIds.map((id: string, idx: number) => ({ - node: { kind: "point" as const, id }, + kind: "point" as const, + id, x: idx + i, y: idx + i, })); - draft.setPositions(updates); + draft.previewPositionPatch(updates); times.push(performance.now() - start); } - draft.finish("translate-many"); + draft.commit("translate-many"); return times; }, { contours, frames: DRAG_FRAMES }, @@ -251,25 +261,25 @@ test.describe("Performance — 50K points", () => { editor.selectAll(); - const snapshot = editor.bridge.getEditingSnapshot(); - const allPoints = snapshot.contours.flatMap((c: any) => c.points); + const allPoints = editor.editGlyphSource.allPoints; const pointIds = allPoints.map((p: any) => p.id); - const draft = editor.createDraft(); + const draft = editor.beginSourceEditDraft({ points: pointIds }); const times: number[] = []; for (let i = 0; i < frames; i++) { const start = performance.now(); const updates = pointIds.map((id: string, idx: number) => ({ - node: { kind: "point" as const, id }, + kind: "point" as const, + id, x: idx + i, y: idx + i, })); - draft.setPositions(updates); + draft.previewPositionPatch(updates); times.push(performance.now() - start); } - draft.finish("translate-all"); + draft.commit("translate-all"); return times; }, { contours, frames: DRAG_FRAMES }, @@ -289,8 +299,7 @@ test.describe("Performance — 50K points", () => { editor.bridge.pasteContours(contours, 0, 0); editor.selectAll(); - const snapshot = editor.bridge.getEditingSnapshot(); - const allPoints = snapshot.contours.flatMap((c: any) => c.points); + const allPoints = editor.editGlyphSource.allPoints; const pointIds = allPoints.map((p: any) => p.id); const times: number[] = []; @@ -320,18 +329,18 @@ test.describe("Performance — 50K points", () => { editor.bridge.pasteContours(contours, 0, 0); editor.selectAll(); - const snapshot = editor.bridge.getEditingSnapshot(); - const allPoints = snapshot.contours.flatMap((c: any) => c.points); + const allPoints = editor.editGlyphSource.allPoints; const pointIds = allPoints.map((p: any) => p.id); - const draft = editor.createDraft(); + const draft = editor.beginSourceEditDraft({ points: pointIds }); const updates = pointIds.map((id: string, idx: number) => ({ - node: { kind: "point" as const, id }, + kind: "point" as const, + id, x: idx + 10, y: idx + 10, })); - draft.setPositions(updates); - draft.finish("pre-undo-translate"); + draft.previewPositionPatch(updates); + draft.commit("pre-undo-translate"); const undoTimes: number[] = []; const redoTimes: number[] = []; @@ -358,7 +367,9 @@ test.describe("Performance — 50K points", () => { assertPerf(redoStats); }); - test("pen tool — rapid point placement on complex glyph", async ({ page }) => { + test("pen tool — rapid point placement on complex glyph", async ({ + page, + }) => { const contours = generateContourData(TARGET_POINTS); const samples = await page.evaluate( diff --git a/apps/desktop/src/main/managers/WindowManager.ts b/apps/desktop/src/main/managers/WindowManager.ts index 2244c859..acee5721 100644 --- a/apps/desktop/src/main/managers/WindowManager.ts +++ b/apps/desktop/src/main/managers/WindowManager.ts @@ -27,6 +27,7 @@ export class WindowManager { this.window = new BrowserWindow({ width: 800, height: 600, + minWidth: 1200, title: "Shift", titleBarStyle: "hidden", trafficLightPosition: { x: -100, y: -100 }, diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index e4f6a7fb..9c9e01e0 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -3,7 +3,8 @@ @font-face { font-family: "Host Grotesk"; - src: url("./src/assets/fonts/HostGrotesk-VariableFont_wght.ttf") format("truetype"); + src: url("./src/assets/fonts/HostGrotesk-VariableFont_wght.ttf") + format("truetype"); font-weight: 100 900; font-style: normal; font-display: swap; @@ -61,6 +62,15 @@ --text-xs: 0.625rem /* 10px */; } +/* Hide scrollbar while preserving scroll behavior */ +@utility scrollbar-hidden { + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +} + /* Custom titlebar styles */ .titlebar-drag { -webkit-app-region: drag; diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index 8ebc92be..315fb769 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -8,7 +8,7 @@ import { DebugProvider } from "@/context/DebugContext"; import { ZoomToast } from "@/components/chrome/ZoomToast"; import { isDev } from "@/lib/utils/utils"; import { dumpSelectionPatternsToConsole } from "@/lib/debug/dumpSelectionPatterns"; -import { clearDirty, getEditor, setFilePath } from "@/store/store"; +import { getDocument } from "@/store/store"; import { documentPersistence } from "@/persistence"; import { RouteDispatcher } from "./RouteDispatcher"; @@ -26,6 +26,10 @@ function isLandingHash(hash: string): boolean { return hash === "" || hash === "#" || hash === "#/"; } +function needsLoadedDocument(hash: string): boolean { + return !isLandingHash(hash); +} + function parseEditorUnicodeFromHash(hash: string): number | null { const match = hash.match(EDITOR_HASH_RE); if (!match) return null; @@ -35,7 +39,8 @@ function parseEditorUnicodeFromHash(hash: string): number | null { export const App = () => { useEffect(() => { - const editor = getEditor(); + const fontDocument = getDocument(); + const editor = fontDocument.editor; documentPersistence.init(editor); let didOpenFont = false; @@ -44,27 +49,23 @@ export const App = () => { }; window.addEventListener("beforeunload", handleBeforeUnload); - const handleOpenFont = (filePath: string, source: "event" | "restore" = "event") => { + const handleOpenFont = ( + filePath: string, + source: "event" | "restore" = "event", + ) => { if (source === "restore" && didOpenFont) { return; } try { - editor.loadFont(filePath); - - setFilePath(filePath); - clearDirty(); - - documentPersistence.openDocument(filePath); + fontDocument.openFont(filePath); didOpenFont = true; if (source === "restore") { const unicode = parseEditorUnicodeFromHash(window.location.hash); if (unicode !== null) { const handle = editor.font.glyphHandleForUnicode(unicode); - if (handle) { - editor.getGlyph(handle); - } + editor.getGlyph(handle); } } else { navigateToHome(); @@ -83,39 +84,47 @@ export const App = () => { const state = documentPersistence.getState(); const mostRecentDocId = state.registry.lruDocIds[0]; - const mostRecentPath = mostRecentDocId ? state.registry.docIdToPath[mostRecentDocId] : null; + const mostRecentPath = mostRecentDocId + ? state.registry.docIdToPath[mostRecentDocId] + : null; if (!mostRecentPath) { + fontDocument.createFont(); return; } const exists = await window.electronAPI?.pathsExist([mostRecentPath]); if (!exists?.[0]) { + fontDocument.createFont(); return; } handleOpenFont(mostRecentPath, "restore"); })(); + } else if ( + needsLoadedDocument(window.location.hash) && + !fontDocument.loaded + ) { + fontDocument.createFont(); } const unsubscribeOpen = window.electronAPI?.onMenuOpenFont(handleOpenFont); - const unsubscribeExternalOpen = window.electronAPI?.onExternalOpenFont(handleOpenFont); - - const unsubscribeSave = window.electronAPI?.onMenuSaveFont(async (savePath) => { - try { - await editor.saveFont(savePath); - setFilePath(savePath); - clearDirty(); - documentPersistence.onDocumentPathChanged(savePath); - documentPersistence.flushNow(); - await window.electronAPI?.saveCompleted(savePath); - } catch (error) { - console.error("Failed to save font:", error); - } - }); + const unsubscribeExternalOpen = + window.electronAPI?.onExternalOpenFont(handleOpenFont); + + const unsubscribeSave = window.electronAPI?.onMenuSaveFont( + async (savePath) => { + try { + await fontDocument.saveFont(savePath); + } catch (error) { + console.error("Failed to save font:", error); + } + }, + ); - const unsubscribePatternDump = window.electronAPI?.onDebugDumpSelectionPatterns(() => { - dumpSelectionPatternsToConsole(); - }); + const unsubscribePatternDump = + window.electronAPI?.onDebugDumpSelectionPatterns(() => { + dumpSelectionPatternsToConsole(); + }); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); diff --git a/apps/desktop/src/renderer/src/app/Document.test.ts b/apps/desktop/src/renderer/src/app/Document.test.ts new file mode 100644 index 00000000..35f9020d --- /dev/null +++ b/apps/desktop/src/renderer/src/app/Document.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { TestEditor } from "@/testing/TestEditor"; +import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; +import { Document, type DocumentPersistence } from "./Document"; + +class InMemoryDocumentPersistence implements DocumentPersistence { + currentDocId: string | null = null; + currentPath: string | null = null; + + closeDocument(): void { + this.currentDocId = null; + this.currentPath = null; + } + + openDocument(filePath: string): void { + this.currentPath = filePath; + this.currentDocId = `file:${filePath}`; + } + + openUntitledDocument(docId: string): void { + this.currentDocId = docId; + this.currentPath = null; + } + + onDocumentPathChanged(filePath: string | null): void { + this.currentPath = filePath; + if (filePath) this.currentDocId ??= `file:${filePath}`; + } + + flushNow(): void {} +} + +function testDocument() { + const editor = new TestEditor(); + const persistence = new InMemoryDocumentPersistence(); + let filePath: string | null = "stale.ufo"; + let dirty = true; + const document = new Document(editor, { + persistence, + createUntitledId: () => "untitled-1", + setFilePath: (nextPath) => { + filePath = nextPath; + }, + clearDirty: () => { + dirty = false; + }, + }); + + return { + document, + editor, + persistence, + get filePath() { + return filePath; + }, + get dirty() { + return dirty; + }, + }; +} + +describe("Document", () => { + it("creates a loaded untitled font document with a default source", () => { + const state = testDocument(); + + state.document.createFont(); + + expect(state.document.identity).toEqual({ + kind: "untitled", + id: "untitled-1", + }); + expect(state.editor.font.loaded).toBe(true); + expect(state.editor.font.defaultSource.name).toBe("Regular"); + expect(state.persistence.currentDocId).toBe("untitled-1"); + expect(state.filePath).toBeNull(); + expect(state.dirty).toBe(false); + }); + + it("opens a file-backed font document", () => { + const state = testDocument(); + + state.document.openFont(MUTATORSANS_DESIGNSPACE); + + expect(state.document.identity).toEqual({ + kind: "file", + path: MUTATORSANS_DESIGNSPACE, + }); + expect(state.editor.font.loaded).toBe(true); + expect(state.editor.font.glyphHandleForName("A")).toEqual({ + name: "A", + unicode: 65, + }); + expect(state.persistence.currentPath).toBe(MUTATORSANS_DESIGNSPACE); + expect(state.filePath).toBe(MUTATORSANS_DESIGNSPACE); + expect(state.dirty).toBe(false); + }); + + it("closes the current document", () => { + const state = testDocument(); + state.document.createFont(); + + state.document.close(); + + expect(state.document.identity).toBeNull(); + expect(state.editor.font.loaded).toBe(false); + expect(state.editor.font.sources).toEqual([]); + expect(state.persistence.currentDocId).toBeNull(); + expect(state.filePath).toBeNull(); + }); + + it("requires a save path for untitled documents", async () => { + const state = testDocument(); + state.document.createFont(); + + await expect(state.document.saveFont()).rejects.toThrow( + "Cannot save an untitled document without a file path", + ); + }); +}); diff --git a/apps/desktop/src/renderer/src/app/Document.ts b/apps/desktop/src/renderer/src/app/Document.ts new file mode 100644 index 00000000..07e4c495 --- /dev/null +++ b/apps/desktop/src/renderer/src/app/Document.ts @@ -0,0 +1,117 @@ +import type { Editor } from "@/lib/editor/Editor"; + +export type DocumentIdentity = + | { readonly kind: "untitled"; readonly id: string } + | { readonly kind: "file"; readonly path: string }; + +export interface DocumentServices { + readonly persistence: DocumentPersistence; + readonly setFilePath: (filePath: string | null) => void; + readonly clearDirty: () => void; + readonly createUntitledId?: () => string; + readonly notifySaveCompleted?: (path: string) => Promise | void; +} + +export interface DocumentPersistence { + closeDocument(): void; + openDocument(filePath: string): void; + openUntitledDocument(docId: string): void; + onDocumentPathChanged(filePath: string | null): void; + flushNow(): void; +} + +/** + * App-level lifecycle for the current font document. + * + * `Document` owns the distinction between no document, a new untitled font, + * and a file-backed font. It coordinates editor font lifecycle, file identity, + * dirty state, and document-scoped persistence. + */ +export class Document { + readonly editor: Editor; + + readonly #persistence: DocumentPersistence; + readonly #setFilePath: (filePath: string | null) => void; + readonly #clearDirty: () => void; + readonly #createUntitledId: () => string; + readonly #notifySaveCompleted: (path: string) => Promise | void; + + #identity: DocumentIdentity | null = null; + + constructor(editor: Editor, services: DocumentServices) { + this.editor = editor; + this.#persistence = services.persistence; + this.#setFilePath = services.setFilePath; + this.#clearDirty = services.clearDirty; + this.#createUntitledId = services.createUntitledId ?? createUntitledId; + this.#notifySaveCompleted = + services.notifySaveCompleted ?? (() => undefined); + } + + get identity(): DocumentIdentity | null { + return this.#identity; + } + + get loaded(): boolean { + return this.editor.font.loaded; + } + + createFont(): void { + this.#persistence.closeDocument(); + + const id = this.#createUntitledId(); + this.editor.createFont(); + this.#identity = { kind: "untitled", id }; + + this.#setFilePath(null); + this.#clearDirty(); + + this.#persistence.openUntitledDocument(id); + this.#persistence.flushNow(); + } + + openFont(path: string): void { + this.#persistence.closeDocument(); + + this.editor.loadFont(path); + this.#identity = { kind: "file", path }; + + this.#setFilePath(path); + this.#clearDirty(); + + this.#persistence.openDocument(path); + this.#persistence.flushNow(); + } + + async saveFont(path?: string): Promise { + const savePath = + path ?? (this.#identity?.kind === "file" ? this.#identity.path : null); + if (!savePath) { + throw new Error("Cannot save an untitled document without a file path"); + } + + await this.editor.saveFont(savePath); + this.#identity = { kind: "file", path: savePath }; + + this.#setFilePath(savePath); + this.#clearDirty(); + + this.#persistence.onDocumentPathChanged(savePath); + this.#persistence.flushNow(); + await this.#notifySaveCompleted(savePath); + } + + close(): void { + this.#persistence.closeDocument(); + this.editor.closeFont(); + this.#identity = null; + this.#setFilePath(null); + this.#clearDirty(); + } +} + +function createUntitledId(): string { + return ( + globalThis.crypto?.randomUUID?.() ?? `untitled-${Date.now().toString(36)}` + ); +} diff --git a/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx b/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx index 8221159e..d9a0dc79 100644 --- a/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx @@ -1,40 +1,15 @@ -import { useLocation } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import { Home } from "@/views/Home"; import { Editor } from "@/views/Editor"; -import { FontInfo } from "@/views/FontInfo"; - -const EDITOR_PATH = /^\/editor\/([^/]+)$/; -const FONT_INFO_PATH = "/info"; export const WorkspaceLayout = () => { - const { pathname } = useLocation(); - const editorMatch = pathname.match(EDITOR_PATH); - const glyphId = editorMatch?.[1]; - const isEditor = !!glyphId; - const isFontInfo = pathname === FONT_INFO_PATH; - const showHome = !isEditor && !isFontInfo; - return (
-
- -
-
- -
- {isEditor && ( -
- -
- )} + + } /> + } /> + } /> +
); }; diff --git a/apps/desktop/src/renderer/src/app/routes.ts b/apps/desktop/src/renderer/src/app/routes.ts index 1743ec44..aaf90d42 100644 --- a/apps/desktop/src/renderer/src/app/routes.ts +++ b/apps/desktop/src/renderer/src/app/routes.ts @@ -2,24 +2,29 @@ import GridSvg from "@assets/toolbar/grid.svg"; import InfoSvg from "@assets/toolbar/info.svg"; import type { SVG } from "@/types/common"; -export interface NavRoute { +type NavItemBase = { id: string; - path: string; description: string; icon?: SVG; -} +}; + +export type NavRoute = NavItemBase & + ({ kind: "route"; path: string } | { kind: "dialog"; dialogId: "font-info" }); export const routes: NavRoute[] = [ { id: "home", + kind: "route", path: "/home", icon: GridSvg, description: "Display all glyphs", }, { id: "info", - path: "/info", + kind: "dialog", + dialogId: "font-info", icon: InfoSvg, - description: "Display and edit font information, such as family name, weight, style, etc.", + description: + "Display and edit font information, such as family name, weight, style, etc.", }, ]; diff --git a/apps/desktop/src/renderer/src/assets/sidebar-left/sidebar-left.svg b/apps/desktop/src/renderer/src/assets/sidebar-left/sidebar-left.svg new file mode 100644 index 00000000..d729a1d3 --- /dev/null +++ b/apps/desktop/src/renderer/src/assets/sidebar-left/sidebar-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx b/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx new file mode 100644 index 00000000..269452df --- /dev/null +++ b/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import { X } from "lucide-react"; +import { + Button, + Dialog, + DialogBackdrop, + DialogClose, + DialogPopup, + DialogPortal, + DialogTitle, + Input, + cn, +} from "@shift/ui"; +import { getEditor } from "@/store/store"; + +interface FontInfoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type CategoryId = "general" | "font" | "sources" | "features" | "shortcuts"; + +const CATEGORIES: { id: CategoryId; label: string }[] = [ + { id: "general", label: "General" }, + { id: "font", label: "Font" }, + { id: "sources", label: "Sources" }, + { id: "features", label: "Features" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +export const FontInfoDialog = ({ open, onOpenChange }: FontInfoDialogProps) => { + const editor = getEditor(); + const [category, setCategory] = useState("font"); + + if (!editor.font.loaded) return null; + + return ( + + + + + Font information + + + +
+ + + + + +
+
+
+
+ ); +}; + +const CategoryPanel = ({ category }: { category: CategoryId }) => { + switch (category) { + case "font": + return ; + case "general": + case "sources": + case "features": + case "shortcuts": + return ( + c.id === category)!.label} + /> + ); + } +}; + +const FontPanel = () => { + const editor = getEditor(); + const m = editor.font.metadata; + const version = + m.versionMajor !== undefined + ? `${m.versionMajor}.${m.versionMinor ?? 0}` + : ""; + + return ( +
+

Font

+ +
+ ); +}; + +interface Field { + label: string; + text: string; +} + +const FieldGrid = ({ fields }: { fields: Field[] }) => ( +
+ {fields.map((f) => ( + + ))} +
+); + +const FieldRow = ({ field }: { field: Field }) => ( + <> + + + +); + +const PlaceholderPanel = ({ label }: { label: string }) => ( +
+

{label}

+

Coming soon.

+
+); diff --git a/apps/desktop/src/renderer/src/components/chrome/NavigationPane.tsx b/apps/desktop/src/renderer/src/components/chrome/NavigationPane.tsx index 0b78244d..1ccbae82 100644 --- a/apps/desktop/src/renderer/src/components/chrome/NavigationPane.tsx +++ b/apps/desktop/src/renderer/src/components/chrome/NavigationPane.tsx @@ -1,33 +1,47 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@shift/ui"; import { routes } from "@/app/routes"; +import { FontInfoDialog } from "./FontInfoDialog"; export const NavigationPane = () => { const navigate = useNavigate(); + const [fontInfoOpen, setFontInfoOpen] = useState(false); return (
{routes.map((route) => { - if (route.icon) { - const Icon = route.icon; - return ( -
+
); }; diff --git a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx index f9a79d8d..bbd7fff2 100644 --- a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx +++ b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx @@ -1,15 +1,37 @@ -import { useRef, useEffect } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useSignalText } from "@/hooks/useSignalText"; import { getEditor } from "@/store/store"; import { Separator } from "@shift/ui"; import { effect } from "@/lib/signals"; +import { useSignalState, useSignalTrigger } from "@/lib/signals/useSignal"; function formatCoords(x: number, y: number): string { return `(${Math.round(x)}, ${Math.round(y)})`; } +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function DebugPanel() { const editor = getEditor(); + const glyphSource = useSignalState(editor.$editGlyphSource); + useSignalTrigger(glyphSource?.structureCell); + const glyphStructure = glyphSource?.structureCell.peek() ?? null; + + const glyphStats = useMemo(() => { + if (!glyphSource) return { pointCount: "0", snapshotSize: "—" }; + + const snapshot = glyphSource.state; + const bytes = new Blob([JSON.stringify(snapshot)]).size; + + return { + pointCount: `${glyphSource.pointCount}`, + snapshotSize: formatBytes(bytes), + }; + }, [glyphSource, glyphStructure]); useEffect(() => { editor.startFpsMonitor(); @@ -28,46 +50,34 @@ export function DebugPanel() { return `${editor.fps.value}`; }); - const pointCountRef = useSignalText(() => { - const glyph = editor.glyph.value; - if (!glyph) return "0"; - - return `${glyph.allPoints.length}`; - }); - - const glyphMemoryRef = useSignalText(() => { - const glyph = editor.glyph.value; - if (!glyph) return "—"; - - const snapshot = glyph.toState(); - const json = JSON.stringify(snapshot); - const bytes = new Blob([json]).size; - - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }); - const upmRef = useRef(null); const screenRef = useRef(null); const worldRef = useRef(null); useEffect(() => { const fx = effect(() => { - const screen = editor.screenMousePosition.value; + const screen = editor.screenMousePositionCell.value; const coords = editor.fromScreen(screen); - if (upmRef.current) upmRef.current.textContent = formatCoords(coords.scene.x, coords.scene.y); - if (screenRef.current) screenRef.current.textContent = formatCoords(screen.x, screen.y); + if (upmRef.current) + upmRef.current.textContent = formatCoords( + coords.scene.x, + coords.scene.y, + ); + if (screenRef.current) + screenRef.current.textContent = formatCoords(screen.x, screen.y); if (worldRef.current) - worldRef.current.textContent = formatCoords(coords.glyphLocal.x, coords.glyphLocal.y); + worldRef.current.textContent = formatCoords( + coords.glyphLocal.x, + coords.glyphLocal.y, + ); }); return () => fx.dispose(); }, [editor]); - const cellClass = "px-2 py-1 border border-line-subtle"; + const cellClass = "px-2 py-1 border"; return ( -
+
Debug Panel @@ -82,18 +92,25 @@ export function DebugPanel() {

FPS

- +

Canvas

Total Points - + + {glyphStats.pointCount} +
Snapshot Size - + + {glyphStats.snapshotSize} +
@@ -104,22 +121,35 @@ export function DebugPanel() { - - + + - - -
SpaceCoordinates + Space + + Coordinates +
UPM +
Screen +
World +
diff --git a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx index c00f7247..0dab5842 100644 --- a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx +++ b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx @@ -3,6 +3,7 @@ import { FC, useEffect, useRef, useState } from "react"; import { CanvasContextProvider } from "@/context/CanvasContext"; import { useDebugSafe } from "@/context/DebugContext"; import { effect } from "@/lib/signals/signal"; +import { useSignalState } from "@/lib/signals"; import { getEditor } from "@/store/store"; import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; @@ -11,6 +12,8 @@ import { DebugPanel } from "../debug/DebugPanel"; import { TextInput } from "../text/HiddenTextInput"; import { Vec2 } from "@shift/geo"; +const GLYPH_ID_RE = /^[0-9a-f]+$/i; + interface EditorViewProps { glyphId: string; } @@ -19,27 +22,33 @@ export const EditorView: FC = ({ glyphId }) => { const editor = getEditor(); const debug = useDebugSafe(); const containerRef = useRef(null); + const fontLoaded = useSignalState(editor.font.$loaded); const [cursorStyle, setCursorStyle] = useState(() => editor.cursor); useEffect(() => { const fx = effect(() => { - setCursorStyle(editor.cursor); + setCursorStyle(editor.cursorCell.value); }); return () => fx.dispose(); }, [editor]); useEffect(() => { + if (!fontLoaded) return undefined; + if (!GLYPH_ID_RE.test(glyphId)) return undefined; + const parsed = Number.parseInt(glyphId, 16); - const unicode = Number.isNaN(parsed) ? 0x41 : parsed; + if (Number.isNaN(parsed)) return undefined; + + const unicode = parsed; const handle = editor.font.glyphHandleForUnicode(unicode); - if (!handle) return undefined; - const initEditor = () => { - editor.getGlyph(handle); - }; + const source = editor.font.sourceAtOrDefault(editor.font.defaultLocation()); + + editor.openGlyph(handle); + editor.openGlyphSource(handle, source.id); - initEditor(); + editor.updateMetricsFromFont(); const toolManager = editor.toolManager; const activeToolId = editor.getActiveTool(); @@ -49,7 +58,7 @@ export const EditorView: FC = ({ glyphId }) => { toolManager.reset(); editor.close(); }; - }, [glyphId]); + }, [editor, fontLoaded, glyphId]); useEffect(() => { const element = containerRef.current; @@ -64,7 +73,6 @@ export const EditorView: FC = ({ glyphId }) => { e.preventDefault(); const zoomFactor = zoomMultiplierFromWheel(e.deltaY, e.deltaMode); editor.zoomToPoint(screenPos.x, screenPos.y, zoomFactor); - editor.requestRedraw(); } else { const currentPan = editor.pan; const newPan = Vec2.sub(currentPan, { x: e.deltaX, y: e.deltaY }); @@ -77,9 +85,8 @@ export const EditorView: FC = ({ glyphId }) => { altKey: e.altKey, metaKey: e.metaKey, }, - { force: true, skipHover: true }, + { force: true }, ); - editor.requestRedraw(); } }; diff --git a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx index 0c88597f..08bc10fc 100644 --- a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx +++ b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx @@ -27,11 +27,18 @@ function glyphChar(codepoint: number): string { } } -export function GlyphFinder({ open, onOpenChange, onSelect }: GlyphFinderProps) { +export function GlyphFinder({ + open, + onOpenChange, + onSelect, +}: GlyphFinderProps) { const { lockToZone, unlock } = useFocusZone(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); const listRef = useRef(null); @@ -114,7 +121,9 @@ export function GlyphFinder({ open, onOpenChange, onSelect }: GlyphFinderProps) }; const selectedColour = (index: number) => - index === selectedIndex ? "bg-accent/10 text-accent" : "text-primary hover:bg-muted/10"; + index === selectedIndex + ? "bg-accent/10 text-accent" + : "text-primary hover:bg-muted/10"; return ( @@ -125,7 +134,11 @@ export function GlyphFinder({ open, onOpenChange, onSelect }: GlyphFinderProps) finalFocus={false} className="max-w-[300px] shadow-sm bg-panel" > -
+
{results.map((result, index) => (
( -