diff --git a/apps/desktop/src/main/document/DocumentSession.ts b/apps/desktop/src/main/document/DocumentSession.ts index 2f2a9bc2..52b9ec7d 100644 --- a/apps/desktop/src/main/document/DocumentSession.ts +++ b/apps/desktop/src/main/document/DocumentSession.ts @@ -1,4 +1,9 @@ -import { dialog, type MessageBoxOptions, type SaveDialogOptions } from "electron"; +import { + dialog, + type MessageBoxOptions, + type OpenDialogOptions, + type SaveDialogOptions, +} from "electron"; import path from "node:path"; import { errorToMessage } from "../../shared/errors"; import type { WorkspaceDocumentState } from "../../shared/workspace/protocol"; @@ -6,6 +11,16 @@ import type { Window } from "../windows/Window"; import type { Document } from "./DocumentClient"; import { createShiftLogger, type ShiftLogger } from "../logging"; +const OPEN_FONT_EXTENSIONS = [ + "shift", + "ttf", + "otf", + "glyphs", + "glyphspackage", + "ufo", + "designspace", +]; + export type DocumentSessionOptions = { document: Document; dialogWindow: () => Window | null; @@ -14,7 +29,7 @@ export type DocumentSessionOptions = { log?: ShiftLogger; }; -export type CloseReason = "window" | "quit"; +export type CloseReason = "window" | "quit" | "replace-document"; type DirtyDocumentChoice = "save" | "discard" | "cancel"; /** @@ -42,6 +57,43 @@ export class DocumentSession { this.#log = options.log ?? createShiftLogger("document.session"); } + /** Creates an untitled workspace through the renderer edit lane. */ + async create(): Promise { + this.#log.info("new document requested"); + if (!(await this.confirmClose("replace-document"))) { + this.#log.info("new document canceled by close guard"); + return; + } + + await this.#document.create(); + this.#log.info("new document created"); + } + + /** Runs Open from main with a native open dialog. */ + async open(): Promise { + this.#log.info("open document requested"); + const openPath = await this.#showOpenDialog(); + if (!openPath) { + this.#log.info("open document canceled before file selection"); + return; + } + + if (!(await this.confirmClose("replace-document"))) { + this.#log.info("open document canceled by close guard", { path: openPath }); + return; + } + + try { + await this.#requestOpen(openPath); + } catch (error) { + this.#log.warn("open document failed", { path: openPath }, error); + await this.#showOpenFailedDialog(openPath, error); + return; + } + + this.#log.info("open document completed", { path: openPath }); + } + /** Returns whether a close transition needs document confirmation. */ shouldConfirmClose(): boolean { const shouldConfirm = this.#document.connected || this.#state?.dirty === true; @@ -78,7 +130,7 @@ export class DocumentSession { return true; } - const choice = await this.#showDirtyDocumentDialog(state); + const choice = await this.#showDirtyDocumentDialog(state, reason); this.#log.info("dirty document dialog completed", { reason, choice, @@ -211,6 +263,11 @@ export class DocumentSession { return state; } + async #requestOpen(openPath: string): Promise { + this.#log.info("document open sent to renderer", { path: openPath }); + await this.#document.open(openPath); + } + async #showSaveDialog(state: WorkspaceDocumentState): Promise { const options: SaveDialogOptions = { title: "Save Shift Document", @@ -227,7 +284,10 @@ export class DocumentSession { return result.canceled ? null : (result.filePath ?? null); } - async #showDirtyDocumentDialog(state: WorkspaceDocumentState): Promise { + async #showDirtyDocumentDialog( + state: WorkspaceDocumentState, + reason: CloseReason, + ): Promise { const name = state.saveTarget ? path.basename(state.saveTarget) : "Untitled"; const options: MessageBoxOptions = { type: "warning", @@ -236,7 +296,7 @@ export class DocumentSession { cancelId: 2, noLink: true, title: this.#applicationName(), - message: this.#dirtyDocumentMessage(name), + message: this.#dirtyDocumentMessage(reason, name), detail: "Your changes will be lost if you don't save them.", }; @@ -269,10 +329,57 @@ export class DocumentSession { await dialog.showMessageBox(options); } - #dirtyDocumentMessage(name: string): string { + async #showOpenFailedDialog(openPath: string, error: unknown): Promise { + const options: MessageBoxOptions = { + type: "error", + buttons: ["OK"], + defaultId: 0, + title: this.#applicationName(), + message: "The font could not be loaded.", + detail: `${openPath}\n\n${errorToMessage(error)}`, + }; + + const window = this.#dialogWindow(); + if (window) { + await dialog.showMessageBox(window.window, options); + return; + } + + await dialog.showMessageBox(options); + } + + #dirtyDocumentMessage(reason: CloseReason, name: string): string { + if (reason === "replace-document") { + return `Save changes to ${name} before replacing it?`; + } + return `Save changes to ${name} before closing?`; } + async #showOpenDialog(): Promise { + const options: OpenDialogOptions = { + title: "Open Font", + filters: [ + { name: "Supported Fonts", extensions: OPEN_FONT_EXTENSIONS }, + { name: "Shift Source Package", extensions: ["shift"] }, + { name: "TrueType/OpenType", extensions: ["ttf", "otf"] }, + { name: "Glyphs", extensions: ["glyphs", "glyphspackage"] }, + { name: "UFO/Designspace", extensions: ["ufo", "designspace"] }, + ], + properties: process.platform === "darwin" ? ["openFile", "openDirectory"] : ["openFile"], + }; + + const window = this.#dialogWindow(); + const result = window + ? await dialog.showOpenDialog(window.window, options) + : await dialog.showOpenDialog(options); + + if (result.canceled) return null; + if (result.filePaths.length !== 1) return null; + + return result.filePaths[0]; + } + #updateWindowTitle(): void { const state = this.#state; const windows = this.#windows(); diff --git a/apps/desktop/src/renderer/src/app/Screens.tsx b/apps/desktop/src/renderer/src/app/Screens.tsx index 87d1d9af..249cc9d2 100644 --- a/apps/desktop/src/renderer/src/app/Screens.tsx +++ b/apps/desktop/src/renderer/src/app/Screens.tsx @@ -76,7 +76,8 @@ const WorkspaceScreens = () => { } } - editor.scene.setLocation(font.defaultLocation()); + editor.setDesignLocation(font.defaultLocation()); + void maximiseWorkspaceWindow(); }, [documentLoaded, editor, font]); diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index 410db789..f24afc9b 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -24,22 +24,21 @@ export const Canvas: FC = () => { useEffect(() => { if (!glyphIdParam) { editor.scene.clear(); - return; + return undefined; } const glyphId = asGlyphId(glyphIdParam); - if (!editor.font.hasGlyph(glyphId)) { + if (!editor.font.recordForId(glyphId)) { editor.scene.clear(); - return; + return undefined; } + editor.scene.clear(); const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); editor.scene.setGeometryItems([itemId]); - return () => { - editor.scene.clear(); - }; - }, [glyphIdParam]); + return () => editor.scene.clear(); + }, [editor, glyphIdParam]); useEffect(() => { const element = containerRef.current; diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 8ad9fdf1..a642cf6b 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -41,15 +41,16 @@ * Row DOM: flex gap-2 px-4; each cell width/maxWidth = cellWidth, min-w-0. */ -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { CELL_HEIGHT, GlyphPreview } from "@/components/home/GlyphPreview"; import { useEditor } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext"; import { Button, Input } from "@shift/ui"; -import type { GlyphName } from "@shift/types"; +import type { GlyphId, GlyphName } from "@shift/types"; +import type { Glyph } from "@/lib/model/Glyph"; const ROW_HEIGHT = CELL_HEIGHT + 40 + 8; const NOMINAL_CELL_WIDTH = 100; @@ -65,9 +66,28 @@ function computeLayout(width: number) { return { columns, cellWidth }; } +function visibleGlyphIdsForRows( + glyphs: readonly GlyphCatalogItem[], + columns: number, + rows: readonly VirtualItem[], +): readonly GlyphId[] { + const glyphIds: GlyphId[] = []; + for (const row of rows) { + const startIndex = row.index * columns; + const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); + for (const glyph of rowGlyphs) { + glyphIds.push(glyph.id); + } + } + return glyphIds; +} + export const GlyphGrid = memo(function GlyphGrid() { const navigate = useNavigate(); const editor = useEditor(); + const font = editor.font; + const metrics = font.metrics; + const designLocation = editor.$designLocation; const { filteredGlyphs: glyphs } = useGlyphCatalog(); const scrollContainerRef = useRef(null); @@ -104,13 +124,46 @@ export const GlyphGrid = memo(function GlyphGrid() { estimateSize: () => ROW_HEIGHT, overscan: OVERSCAN, }); + const virtualRows = virtualizer.getVirtualItems(); + const visibleRowsKey = virtualRows.map((row) => row.index).join(","); + const visibleGlyphIds = useMemo( + () => visibleGlyphIdsForRows(glyphs, columns, virtualRows), + [glyphs, columns, visibleRowsKey], + ); + const [loadedGlyphs, setLoadedGlyphs] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + + async function load(): Promise { + try { + const loaded = await font.loadGlyphs(visibleGlyphIds); + if (!cancelled) setLoadedGlyphs(loaded); + } catch (error) { + console.error("failed to load visible glyphs", error); + if (!cancelled) setLoadedGlyphs(new Map()); + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [font, visibleGlyphIds]); const handleCellClick = useCallback( - (glyph: GlyphCatalogItem) => { - if (!glyph.id) return; - navigate(`/editor/${encodeURIComponent(glyph.id)}`); + async (glyph: GlyphCatalogItem) => { + try { + const loadedGlyph = await font.loadGlyph(glyph.id); + if (!loadedGlyph) return; + + navigate(`/editor/${encodeURIComponent(glyph.id)}`); + } catch (error) { + console.error("failed to load glyph", error); + } }, - [navigate], + [font, navigate], ); return ( @@ -130,7 +183,7 @@ export const GlyphGrid = memo(function GlyphGrid() { position: "relative", }} > - {virtualizer.getVirtualItems().map((virtualRow) => { + {virtualRows.map((virtualRow) => { const startIndex = virtualRow.index * columns; const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); return ( @@ -145,27 +198,36 @@ export const GlyphGrid = memo(function GlyphGrid() { }} className="flex gap-2 px-4" > - {rowGlyphs.map((glyph) => ( -
- - -
- ))} + + + + ); + })} ); })} @@ -187,7 +249,7 @@ function GlyphNameInput({ glyph }: { readonly glyph: GlyphCatalogItem }) { const commit = () => { const next = draft.trim() as GlyphName; - if (!glyph.exists || next === glyphName) { + if (next === glyphName) { setDraft(glyphName); return; } @@ -204,7 +266,6 @@ function GlyphNameInput({ glyph }: { readonly glyph: GlyphCatalogItem }) { return ( setDraft(event.currentTarget.value as GlyphName)} onBlur={commit} onKeyDown={(event) => { diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 3dcda77e..675a50c2 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -1,9 +1,7 @@ import type { FontMetrics } from "@shift/types"; -import type { Font } from "@/lib/model/Font"; import type { Glyph } from "@/lib/model/Glyph"; -import { useSignalState } from "@/lib/signals"; -import { useEditor } from "@/workspace/WorkspaceContext"; -import type { GlyphHandle } from "@shift/bridge"; +import { type Signal, useSignalState } from "@/lib/signals"; +import type { AxisLocation } from "@/types/variation"; export const CELL_HEIGHT = 75; @@ -45,41 +43,68 @@ export function computeCellWidth( } interface GlyphPreviewProps { - handle: GlyphHandle; - font: Font; + glyph: Glyph | null; + unicode: number | null; + metrics: FontMetrics; + designLocation: Signal; height?: number; } -export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPreviewProps) { - if (!font.loaded) { - return ; - } - - const glyph = font.glyph(handle); +export function GlyphPreview({ + glyph, + unicode, + metrics, + designLocation, + height = CELL_HEIGHT, +}: GlyphPreviewProps) { if (!glyph) { - return ; + return ; } - return ; + return ( + + ); } -function GlyphCell({ font, height, glyph }: { font: Font; height: number; glyph: Glyph }) { - const editor = useEditor(); - const outline = glyph.instance(editor.$designLocation).render.outline; +function GlyphCell({ + metrics, + height, + glyph, + unicode, + designLocation, +}: { + metrics: FontMetrics; + height: number; + glyph: Glyph; + unicode: number | null; + designLocation: Signal; +}) { + const outline = glyph.instance(designLocation).render.outline; const svgPath = useSignalState(outline.$svgPath); const advance = useSignalState(glyph.$xAdvance); - const fontMetrics = font.metrics; - - const cellWidth = computeCellWidth(fontMetrics, advance, height); + const cellWidth = computeCellWidth(metrics, advance, height); const containerStyle = { width: cellWidth, height }; if (!svgPath) { - return ; + return ( + + ); } - const viewBox = glyphPreviewViewBox(fontMetrics, advance); + const viewBox = glyphPreviewViewBox(metrics, advance); return (
@@ -99,23 +124,18 @@ function GlyphCell({ font, height, glyph }: { font: Font; height: number; glyph: } function FallbackCell({ - handle, - font, + metrics, height, + unicode, advance, }: { - handle: GlyphHandle; - font: Font; + metrics: FontMetrics; height: number; + unicode: number | null; advance: number | null; }) { - const cellWidth = computeCellWidth(font.metrics, advance, height); - let label: string = ""; - - const record = font.recordForName(handle.name); - if (record && record.unicodes.length > 0) { - label = String.fromCodePoint(record.unicodes[0]); - } + const cellWidth = computeCellWidth(metrics, advance, height); + const label = unicode === null ? "" : String.fromCodePoint(unicode); return (
{ const { - availableGlyphs, + availableGlyphs: allGlyphs, filteredGlyphs, categories, query, @@ -30,6 +30,9 @@ export const GlyphCatalog = () => { selectSubCategory, } = useGlyphCatalog(); + const allGlyphCount = allGlyphs.length; + const filteredGlyphCount = filteredGlyphs.length; + const allGlyphsSelected = selectedCategory === null && selectedSubCategoryKey === null; const isTopLevelCategorySelected = selectedCategory !== null && selectedSubCategoryKey === null; return ( @@ -62,13 +65,13 @@ export const GlyphCatalog = () => { variant="ghost" size="sm" onClick={selectAll} - isActive={selectedCategory === null && selectedSubCategoryKey === null} + isActive={allGlyphsSelected} >
All
- {`${filteredGlyphs.length}/${availableGlyphs.length}`} + {`${filteredGlyphCount}/${allGlyphCount}`}
diff --git a/apps/desktop/src/renderer/src/components/variation/Sources.tsx b/apps/desktop/src/renderer/src/components/variation/Sources.tsx index f0b49fc6..72f0d320 100644 --- a/apps/desktop/src/renderer/src/components/variation/Sources.tsx +++ b/apps/desktop/src/renderer/src/components/variation/Sources.tsx @@ -25,7 +25,7 @@ export const Sources = () => { const deleteSource = (sourceId: SourceId) => { const fallbackSource = sources.find((source) => source.id !== sourceId); if (layerSourceId === sourceId && fallbackSource) { - editor.selectLayerSource(fallbackSource.id); + editor.selectSource(fallbackSource.id); } editor.font.deleteSource(sourceId); }; @@ -36,7 +36,7 @@ export const Sources = () => { editor.selectLayerSource(s.id)} + onClick={() => editor.selectSource(s.id)} contentClassName="h-6 text-ui" actions={ { const glyphInfo = getGlyphInfo(); const font = editor.font; - const fontLoaded = useSignalState(font.$loaded); const glyphRecords = useSignalState(font.glyphRecordsCell); const [query, setQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedSubCategoryKey, setSelectedSubCategoryKey] = useState(null); - const starterUnicodes = useMemo(() => [], []); - const availableGlyphs = useMemo( - () => - fontLoaded && glyphRecords.length > 0 - ? glyphRecords.map(glyphCatalogItemFromRecord) - : starterUnicodes.map( - (unicode): GlyphCatalogItem => ({ - id: null, - name: font.nameForUnicode(unicode), - unicode, - exists: false, - }), - ), - [font, fontLoaded, glyphRecords, starterUnicodes], + () => glyphRecords.map(glyphCatalogItemFromRecord), + [glyphRecords], ); const availableUnicodes = useMemo( @@ -152,6 +131,5 @@ function glyphCatalogItemFromRecord(record: GlyphRecord): GlyphCatalogItem { id: record.id, name: record.name, unicode: record.unicodes[0] ?? null, - exists: true, }; } 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 bd594f12..bac8690b 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts @@ -28,6 +28,39 @@ describe("Editor", () => { expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); }); + it("derives active glyph from the geometry-shown scene item", () => { + const record = editor.font.recordForName("S")!; + + editor.scene.clear(); + const itemId = editor.scene.addGlyph({ glyphId: record.id, origin: { x: 0, y: 0 } }); + editor.scene.setGeometryItems([itemId]); + + expect(editor.scene.value.items).toHaveLength(1); + expect(editor.scene.item(itemId)).toMatchObject({ + kind: "glyph", + glyphId: record.id, + placement: { origin: { x: 0, y: 0 } }, + }); + expect(editor.scene.isGeometryShown(itemId)).toBe(true); + expect(editor.glyph.peek()?.id).toBe(record.id); + expect(editor.layerForItem(itemId)).not.toBeNull(); + }); + + it("clearing the scene clears the derived active glyph", () => { + const record = editor.font.recordForName("S")!; + + editor.scene.clear(); + const itemId = editor.scene.addGlyph({ glyphId: record.id, origin: { x: 0, y: 0 } }); + editor.scene.setGeometryItems([itemId]); + + expect(editor.glyph.peek()?.id).toBe(record.id); + + editor.scene.clear(); + + expect(editor.scene.value.items).toEqual([]); + expect(editor.glyph.peek()).toBeNull(); + }); + it("can place the same glyph id twice with distinct item ids", async () => { const record = editor.font.recordForName("A")!; const left = mintItemId(); @@ -100,49 +133,8 @@ describe("Editor", () => { }); }); - // 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 items when switching back to Text after a slot drill-in", () => { - // A is the main glyph (the one the user "opened from the grid"). - const owner = editor.font.glyphHandleForUnicode(65)!; - const ownerKey = owner.name; - editor.setRootGlyphHandle(owner); - - editor.selectTool("text"); - editor.textRun.insert(glyphTextItem("S", 83)); - expect(editor.textRun.buffer.items).toHaveLength(2); - - // Drill into slot 1 (the S): mirrors what TextRunEdit does on dblclick. - editor.selectTool("select"); - const sItem = editor.textRun.buffer.items[1]; - expect(sItem.kind).toBe("glyph"); - editor.setGlyphFocus({ runId: editor.textRun.id, itemId: sItem.id }); - expect(editor.focusedGlyph?.glyph.name).toBe("S"); - // Main glyph (run owner) hasn't moved. - expect(editor.rootGlyphHandle!.name).toBe(ownerKey); - - // Toggle back to Text. The run should still be the A-keyed run, with - // its items preserved — not a fresh S-keyed run. - editor.selectTool("text"); - - expect(editor.textRun.buffer.items).toHaveLength(2); - expect(editor.textRun.buffer.items[0]).toMatchObject({ - kind: "glyph", - glyphName: ownerKey, - codepoint: 65, - }); - expect(editor.textRun.buffer.items[1]).toBe(sItem); - }); - }); - describe("glyph focus placement", () => { it("recomputes drawOffset from the focused item after inserting a linebreak before it", () => { - const owner = editor.font.glyphHandleForUnicode(65)!; - editor.setRootGlyphHandle(owner); editor.selectTool("text"); const s = glyphTextItem("S", 83); editor.textRun.insert(s); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 4aa9147b..63108a8a 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -6,13 +6,16 @@ import type { SourceId, GlyphName, GlyphRecord, - GlyphId, ItemId, } from "@shift/types"; import type { AxisLocation } from "@/types/variation"; import type { Coordinates } from "@/types/coordinates"; import type { Glyph, GlyphInstance, GlyphLayer } from "@/lib/model/Glyph"; -import { axisLocationFromLocation } from "@/lib/variation/location"; +import { + axisLocationFromLocation, + cloneAxisLocation, + emptyAxisLocation, +} from "@/lib/variation/location"; import type { ToolName, ActiveToolState } from "../tools/core"; import { ToolManager } from "../tools/core/ToolManager"; import { Segment } from "@shift/glyph-state"; @@ -65,7 +68,6 @@ import { Renderer } from "./rendering/Renderer"; import type { Canvas2DSurface, MarkerCanvasSurface } from "./rendering/CanvasSurface"; import type { CameraTransform } from "./managers"; import type { FocusZone } from "@/types/focus"; -import type { GlyphHandle } from "@shared/bridge/BridgeApi"; import type { DebugOverlays } from "@/types/uiState"; import type { TemporaryToolOptions } from "@/types/editor"; import { Selection } from "./Selection"; @@ -186,6 +188,7 @@ export class Editor { * queries. */ #glyph: EditorGlyphState; + #designLocation: WritableSignal; #cursorEffect: Effect; #cameraMetricsEffect: Effect; @@ -226,7 +229,8 @@ export class Editor { this.font = options.font; this.scene = new Scene(); - this.#glyph = new EditorGlyphState(this.font, this.scene.locationCell); + this.#designLocation = signal(emptyAxisLocation(), { name: "editor.designLocation" }); + this.#glyph = new EditorGlyphState(this.font, this.scene, this.#designLocation); this.#view = new EditorViewState(); this.input = new EditorInput(); @@ -265,7 +269,7 @@ export class Editor { this.#clipboard = new Clipboard(options.clipboard); - this.#textRuns = new TextRuns(this.font, new Positioner(), this.scene.locationCell); + this.#textRuns = new TextRuns(this.font, new Positioner(), this.#designLocation); this.#text = new TextEditingState(this.#textRuns); this.#glyphDisplay = new GlyphDisplay(this.#text, this.#textRuns); @@ -504,53 +508,6 @@ export class Editor { this.#renderer.clearMarkerCanvas(); } - /** - * Focus an existing glyph model in the editor. - * - * This is a read/focus API. It chooses the current active source context for - * camera metrics, asks `Font` for existing glyph state, and updates - * `editingGlyph` when the glyph can be loaded. It does not create missing - * glyph data and does not select an authored glyph layer. - * - * @returns The focused glyph model, or `null` when the glyph has no readable state. - */ - #focusGlyphHandle(handle: GlyphHandle): Glyph | null { - const glyph = this.font.glyph(handle); - if (!glyph) return null; - - this.#glyph.open.glyph.set(glyph); - return glyph; - } - - public setRootGlyphHandle(handle: GlyphHandle | null): void { - this.#glyph.open.rootHandle.set(handle); - } - - public get rootGlyphHandle(): GlyphHandle | null { - return this.#glyph.open.rootHandle.peek(); - } - - /** - * Focuses an existing document glyph as the active glyph model. - * - * @param glyphId - Document glyph identity to focus. - * @returns The loaded glyph model, or `null` when the glyph cannot be read. - */ - public focusGlyph(glyphId: GlyphId, location: AxisLocation): Glyph | null { - const record = this.font.recordForId(glyphId); - if (!record) { - this.#glyph.open.glyph.set(null); - this.#glyph.open.rootHandle.set(null); - return null; - } - - const handle = this.font.glyphHandleForName(record.name); - this.#glyph.open.rootHandle.set(handle); - this.scene.setLocation(location); - this.#glyph.layerEditing.followDesignLocation(); - return this.#focusGlyphHandle(handle); - } - /** * Creates an empty glyph in the loaded font. * @@ -582,7 +539,12 @@ export class Editor { batch(() => { this.#text.glyphAnchor.set(anchor); - this.#focusGlyphHandle(focused.glyph); + const record = this.font.recordForName(focused.glyph.name); + if (record) { + this.font.loadGlyph(record.id).catch((error) => { + console.error("failed to load focused text glyph", error); + }); + } this.disableProofMode(); }); } @@ -606,32 +568,30 @@ export class Editor { /** Clears the current glyph focus and active contour selection. */ public close(): void { this.scene.clear(); - this.#glyph.open.glyph.set(null); - this.#glyph.open.activeContourId.set(null); + this.#glyph.active.activeContourId.set(null); } public get $designLocation(): Signal { - return this.scene.locationCell; + return this.#designLocation; } /** Current designspace coordinate used for displayed glyph data. */ public get designLocation(): AxisLocation { - return this.scene.location; + return this.#designLocation.peek(); } /** * Reactive ID of the designspace source selected for layer editing. * - * `null` means layer editing follows the exact source at `designLocation`, - * if one exists. + * `null` means the current design location does not exactly match a source. */ public get $layerSourceId(): Signal { - return this.#glyph.layerEditing.selectedSourceId; + return this.#glyph.layerEditing.sourceId; } - /** ID of the designspace source selected for layer editing, or `null` for location fallback. */ + /** ID of the exact designspace source at the current location, or `null`. */ public get layerSourceId(): SourceId | null { - return this.#glyph.layerEditing.selectedSourceId.peek(); + return this.#glyph.layerEditing.sourceId.peek(); } /** Font source currently selected for layer editing, or `null` when unavailable. */ @@ -659,23 +619,10 @@ export class Editor { } /** - * Set the displayed designspace coordinate and synchronize layer-source focus. - * - * If the location exactly matches an authored glyph layer, that source becomes the - * explicit layer-editing source. Otherwise layer editing falls back to - * location resolution and may be `null`. + * Set the displayed designspace coordinate shared by editor views. */ public setDesignLocation(location: AxisLocation): void { - batch(() => { - this.scene.setLocation(location); - - const source = this.font.sourceAt(location); - if (source) { - this.#glyph.layerEditing.selectLayerSource(source.id); - } else { - this.#glyph.layerEditing.followDesignLocation(); - } - }); + this.#designLocation.set(cloneAxisLocation(location)); } /** @@ -701,28 +648,21 @@ export class Editor { /** * Select an authored glyph layer for editing and move the display location to it. * - * Missing source IDs are ignored. This does not open a glyph; it retargets - * the current open glyph's layer source when one is available. + * Missing source IDs are ignored. This does not open a glyph; it moves the + * shared design location to the source. */ - public selectLayerSource(sourceId: SourceId): void { + public selectSource(sourceId: SourceId): void { const source = this.font.source(sourceId); if (!source) return; - batch(() => { - const location = axisLocationFromLocation(source.location); - this.scene.setLocation(location); - this.#glyph.layerEditing.selectLayerSource(source.id); - }); + this.setDesignLocation(axisLocationFromLocation(source.location)); } /** - * Clear explicit layer-source selection. - * - * The editor will resolve the layer source from the current design location - * until another layer source is selected. + * Return the shared design location to the font default. */ - public clearLayerSourceSelection(): void { - this.#glyph.layerEditing.followDesignLocation(); + public setSourceToDefault(): void { + this.setDesignLocation(this.font.defaultLocation()); } public get textRuns(): TextRuns { @@ -738,6 +678,12 @@ export class Editor { public insertTextCodepoint(codepoint: number): void { const handle = this.font.glyphHandleForUnicode(codepoint); if (!handle) return; + const record = this.font.recordForName(handle.name); + if (record) { + this.font.loadGlyph(record.id).catch((error) => { + console.error("failed to load inserted text glyph", error); + }); + } this.textRun.insert(glyphTextItem(handle.name, codepoint)); } @@ -764,7 +710,7 @@ export class Editor { } public get glyph(): Signal { - return this.#glyph.open.glyph; + return this.#glyph.active.glyph; } public get glyphInstanceCell(): Signal { @@ -775,27 +721,22 @@ export class Editor { const item = this.scene.glyphItem(itemId); if (!item) return null; - const record = this.font.recordForId(item.glyphId); - if (!record) return null; - - return this.font.glyph(this.font.glyphHandleForName(record.name)); + return this.font.glyphForId(item.glyphId); } public instanceForItem(itemId: ItemId): GlyphInstance | null { const item = this.scene.glyphItem(itemId); const glyph = item ? this.glyphForItem(itemId) : null; if (!item || !glyph) return null; - return glyph.instance(this.scene.locationCell); + return glyph.instance(this.#designLocation); } public layerForItem(itemId: ItemId): GlyphLayer | null { const item = this.scene.glyphItem(itemId); if (!item) return null; - const source = this.font.sourceAt(this.scene.location); + const source = this.font.sourceAt(this.designLocation); if (!source) return null; - const record = this.font.recordForId(item.glyphId); - if (!record) return null; - return this.font.glyphLayer(this.font.glyphHandleForName(record.name), source); + return this.font.glyphLayerForId(item.glyphId, source.id); } /** Stateless command executor; undo authority is the workspace ledger. */ @@ -1073,7 +1014,7 @@ export class Editor { const content = this.#selectedClipboardContent(); if (!content || content.contours.length === 0) return false; - const glyph = this.#glyph.open.glyph.peek(); + const glyph = this.glyph.peek(); if (!glyph) return false; return this.#clipboard.write(content, { sourceGlyph: glyph.name }); @@ -1083,7 +1024,7 @@ export class Editor { const content = this.#selectedClipboardContent(); if (!content || content.contours.length === 0) return false; - const glyph = this.#glyph.open.glyph.peek(); + const glyph = this.glyph.peek(); if (!glyph) return false; const written = await this.#clipboard.write(content, { @@ -1214,22 +1155,22 @@ export class Editor { } public get activeContourIdCell(): Signal { - return this.#glyph.open.activeContourId; + return this.#glyph.active.activeContourId; } public getActiveContourId(): ContourId | null { - const id = this.#glyph.open.activeContourId.peek(); + const id = this.#glyph.active.activeContourId.peek(); if (!id) return null; return id; } public setActiveContour(contourId: ContourId | null): void { - this.#glyph.open.activeContourId.set(contourId); + this.#glyph.active.activeContourId.set(contourId); } public clearActiveContour(): void { - this.#glyph.open.activeContourId.set(null); + this.#glyph.active.activeContourId.set(null); } public getActiveContour(): Contour | null { @@ -1240,7 +1181,7 @@ export class Editor { } public continueContour(contourId: ContourId, fromStart: boolean, pointId: PointId): void { - this.#glyph.open.activeContourId.set(contourId); + this.#glyph.active.activeContourId.set(contourId); if (fromStart) { this.#commands.run(new ReverseContourCommand(contourId)); } diff --git a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts index 9b3d6cca..4dbdf12a 100644 --- a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts +++ b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts @@ -1,12 +1,12 @@ import type { Point2D } from "@shift/geo"; import type { ContourId, LayerId, Source, SourceId } from "@shift/types"; -import type { GlyphHandle } from "@shared/bridge/BridgeApi"; import type { Coordinates } from "@/types/coordinates"; import type { AxisLocation } from "@/types/variation"; import type { DebugOverlays } from "@/types/uiState"; import type { Modifiers } from "../tools/core/GestureDetector"; import type { Font } from "../model/Font"; import type { Glyph, GlyphInstance, GlyphLayer } from "../model/Glyph"; +import type { Scene } from "./Scene"; import type { FocusedGlyph } from "../text/TextRun"; import type { GlyphAnchor } from "../text/layout"; import type { TextRuns } from "../text/TextRuns"; @@ -182,32 +182,31 @@ export class EditorViewState { } } -/** - * Stores the glyph focus for the editor session. - * - * `glyph` is the loaded model shown by the editor, `rootHandle` is the - * top-level glyph opened by the route or text-run focus, and `activeContourId` - * is pen-tool continuation state. - */ -export class OpenGlyphState { - /** Loaded glyph model for the current editor focus, or `null` when nothing is open. */ - readonly glyph: WritableSignal; - - /** Top-level glyph handle selected by the editor route/text run. */ - readonly rootHandle: WritableSignal; +/** Active glyph state derived from placed scene items. */ +export class ActiveGlyphState { + /** Glyph model for the first geometry-shown glyph item, or first placed glyph. */ + readonly glyph: Signal; /** Contour receiving appended pen points, or `null` when no contour is active. */ readonly activeContourId: WritableSignal; - constructor() { - this.glyph = signal(null, { - name: "editor.glyph.open.glyph", - }); - this.rootHandle = signal(null, { - name: "editor.glyph.open.rootHandle", - }); + constructor(font: Font, scene: Scene) { + this.glyph = computed( + () => { + const value = scene.cell.value; + font.glyphRecordsCell.value; + font.glyphSnapshotStatusCell.value; + const geometryItem = value.geometryItems + .map((itemId) => value.items.find((item) => item.id === itemId) ?? null) + .find((item) => item?.kind === "glyph"); + const fallbackItem = value.items.find((item) => item.kind === "glyph") ?? null; + const item = geometryItem ?? fallbackItem; + return item?.kind === "glyph" ? font.glyphForId(item.glyphId) : null; + }, + { name: "editor.glyph.active" }, + ); this.activeContourId = signal(null, { - name: "editor.glyph.open.activeContourId", + name: "editor.glyph.activeContourId", }); } } @@ -220,14 +219,14 @@ export interface GlyphLayerResolution { /** * Resolves the authored glyph layer for the current designspace source. * - * `selectedSourceId === null` means the source follows the exact source at the - * current design location when one exists. + * The editable source follows the exact source at the current design location + * when one exists. */ export class GlyphLayerEditingState { - /** Explicit designspace source selected for layer editing, or `null` for location fallback. */ - readonly selectedSourceId: WritableSignal; + /** ID for the exact designspace source selected by the current design location. */ + readonly sourceId: Signal; - /** Font source selected for layer editing, or by exact design-location fallback. */ + /** Exact font source selected by the current design location. */ readonly selectedSource: Signal; /** Selected source with the current glyph's authored layer when one exists. */ @@ -236,31 +235,26 @@ export class GlyphLayerEditingState { /** Authored glyph layer data for the selected source. */ readonly glyphLayer: Signal; - constructor(font: Font, open: OpenGlyphState, location: Signal) { - this.selectedSourceId = signal(null, { - name: "editor.glyph.layerEditing.sourceId", - }); - + constructor(font: Font, glyph: Signal, location: Signal) { this.selectedSource = computed( () => { - const sourceId = this.selectedSourceId.value; - if (sourceId) return font.source(sourceId); - return font.sourceAt(location.value); }, { name: "editor.glyph.layerEditing.source" }, ); + this.sourceId = computed(() => this.selectedSource.value?.id ?? null, { + name: "editor.glyph.layerEditing.sourceId", + }); this.layer = computed( () => { const source = this.selectedSource.value; if (!source) return null; - const glyph = open.glyph.value; - if (!glyph) return { sourceId: source.id, layerId: null }; + const activeGlyph = glyph.value; + if (!activeGlyph) return { sourceId: source.id, layerId: null }; - const record = font.recordForName(glyph.handle.name); - const layerId = record ? (font.glyphLayerRecord(record.id, source.id)?.id ?? null) : null; + const layerId = font.layerRecordForId(activeGlyph.id, source.id)?.id ?? null; return { sourceId: source.id, layerId }; }, { name: "editor.glyph.layerEditing.layer" }, @@ -268,38 +262,18 @@ export class GlyphLayerEditingState { this.glyphLayer = computed( () => { - const glyph = open.glyph.value; - if (!glyph) return null; + const activeGlyph = glyph.value; + if (!activeGlyph) return null; const source = this.selectedSource.value; if (!source) return null; if (this.layer.value?.layerId === null) return null; - return font.glyphLayer(glyph.handle, source); + return font.glyphLayerForId(activeGlyph.id, source.id); }, { name: "editor.glyph.layerEditing.glyphLayer" }, ); } - - /** - * Select an explicit designspace source for layer editing. - * - * Resolution is reactive: `source` and `glyphLayer` update after this id is - * set, and either may be `null` if the source or open glyph is unavailable. - */ - selectLayerSource(sourceId: SourceId): void { - this.selectedSourceId.set(sourceId); - } - - /** - * Clear explicit source selection. - * - * After this call, `source` resolves from the exact source at the current - * design location, and `glyphLayer` follows that source when a glyph is open. - */ - followDesignLocation(): void { - this.selectedSourceId.set(null); - } } /** @@ -313,13 +287,13 @@ export class PreviewGlyphState { /** Glyph resolved at the current design location. */ readonly instance: Signal; - constructor(open: OpenGlyphState, location: Signal) { + constructor(glyph: Signal, location: Signal) { this.instance = computed( () => { - const glyph = open.glyph.value; - if (!glyph) return null; + const activeGlyph = glyph.value; + if (!activeGlyph) return null; - return glyph.instance(location); + return activeGlyph.instance(location); }, { name: "editor.glyph.preview.instance" }, ); @@ -332,14 +306,14 @@ export class PreviewGlyphState { * displayed glyph model at the current design location. */ export class EditorGlyphState { - readonly open: OpenGlyphState; + readonly active: ActiveGlyphState; readonly layerEditing: GlyphLayerEditingState; readonly preview: PreviewGlyphState; - constructor(font: Font, location: Signal) { - this.open = new OpenGlyphState(); - this.layerEditing = new GlyphLayerEditingState(font, this.open, location); - this.preview = new PreviewGlyphState(this.open, location); + constructor(font: Font, scene: Scene, location: Signal) { + this.active = new ActiveGlyphState(font, scene); + this.layerEditing = new GlyphLayerEditingState(font, this.active.glyph, location); + this.preview = new PreviewGlyphState(this.active.glyph, location); } } diff --git a/apps/desktop/src/renderer/src/lib/editor/Scene.ts b/apps/desktop/src/renderer/src/lib/editor/Scene.ts index d492e881..b76abecd 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Scene.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Scene.ts @@ -1,8 +1,6 @@ import type { Point2D } from "@shift/geo"; import { mintItemId, type GlyphId, type ItemId } from "@shift/types"; -import type { AxisLocation } from "@/types/variation"; -import { cloneAxisLocation, emptyAxisLocation } from "@/lib/variation/location"; -import { computed, signal, type Signal, type WritableSignal } from "@/lib/signals"; +import { signal, type Signal, type WritableSignal } from "@/lib/signals"; export interface ScenePlacement { readonly origin: Point2D; @@ -23,13 +21,11 @@ export interface AddGlyphInput { } export interface SceneValue { - readonly location: AxisLocation; readonly items: readonly SceneItem[]; readonly geometryItems: readonly ItemId[]; } export interface SceneInput { - readonly location?: AxisLocation; readonly items: readonly SceneItem[]; readonly geometryItems?: readonly ItemId[]; } @@ -38,20 +34,15 @@ export interface SceneInput { * Owns the placed items visible in the editor scene. * * @remarks - * Scene stores the scene-wide designspace location, placement identity, and - * which item ids should show structured geometry. It does not resolve glyph - * models or glyph layers, and it does not decide what authored data a command - * mutates. + * Scene stores placement identity and which item ids should show structured + * geometry. It does not store designspace location, resolve glyph models or + * glyph layers, or decide what authored data a command mutates. */ export class Scene { readonly #cell: WritableSignal; - readonly #locationCell: Signal; constructor() { this.#cell = signal(emptyScene(), { name: "editor.scene" }); - this.#locationCell = computed(() => this.#cell.value.location, { - name: "editor.scene.location", - }); } /** Reactive scene value for renderers and panels. */ @@ -64,26 +55,6 @@ export class Scene { return this.#cell.peek(); } - /** Reactive designspace location shown by this scene. */ - get locationCell(): Signal { - return this.#locationCell; - } - - /** Current designspace location shown by this scene. */ - get location(): AxisLocation { - return this.#cell.peek().location; - } - - /** - * Sets the designspace location used to render scene glyphs. - * - * @param location - Designspace coordinate shared by every glyph item in the scene. - */ - setLocation(location: AxisLocation): void { - const scene = this.#cell.peek(); - this.#cell.set({ ...scene, location: cloneAxisLocation(location) }); - } - /** Returns all placed scene items as a read-only snapshot. */ items(): readonly SceneItem[] { return this.#cell.peek().items; @@ -131,9 +102,7 @@ export class Scene { const items = scene.items.map(copyItem); const itemIds = new Set(items.map((item) => item.id)); const geometryItems = (scene.geometryItems ?? []).filter((itemId) => itemIds.has(itemId)); - const current = this.#cell.peek(); this.#cell.set({ - location: cloneAxisLocation(scene.location ?? current.location), items, geometryItems, }); @@ -322,5 +291,5 @@ function copyPlacement(placement: ScenePlacement): ScenePlacement { } function emptyScene(): SceneValue { - return { location: emptyAxisLocation(), items: [], geometryItems: [] }; + return { items: [], geometryItems: [] }; } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts index 876a9579..82cf18a3 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts @@ -8,6 +8,7 @@ import type { Editor } from "@/lib/editor/Editor"; import type { GlyphDisplayState } from "@/lib/editor/EditorState"; import type { SceneGlyph } from "@/lib/editor/Scene"; import type { AxisLocation } from "@/types/variation"; +import type { GlyphRecord, Unicode } from "@shift/types"; import { track, type Signal } from "@/lib/signals"; import { displayAdvance } from "@/lib/utils/unicode"; import { SCREEN_HIT_RADIUS } from "./constants"; @@ -20,11 +21,16 @@ import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; interface BackgroundGlyphFrame { readonly item: SceneGlyph; - readonly model: Glyph; readonly advance: number; readonly geometryShown: boolean; } +interface GuideAdvanceInput { + readonly record: GlyphRecord; + readonly model: Glyph | null; + readonly xAdvance: number; +} + export interface BackgroundLayerProps { readonly glyphs: readonly BackgroundGlyphFrame[]; } @@ -100,20 +106,22 @@ export class BackgroundLayer extends CanvasItem { for (const item of scene.items) { if (item.kind !== "glyph") continue; + const geometryShown = scene.geometryItems.includes(item.id); + if (!geometryShown) continue; - const glyph = this.#editor.glyphForItem(item.id); - if (!glyph) continue; + const record = this.#editor.font.recordForId(item.glyphId); + if (!record) continue; + const glyph = this.#editor.glyphForItem(item.id); const instance = this.#editor.instanceForItem(item.id); - if (!instance) continue; - - const xAdvance = instance.xAdvanceCell.value; - const unicode = Number.isFinite(glyph.unicode) ? glyph.unicode : null; glyphs.push({ item, - model: glyph, - advance: displayAdvance(xAdvance, glyph.name, unicode), - geometryShown: scene.geometryItems.includes(item.id), + advance: guideAdvance({ + record, + model: glyph, + xAdvance: instance?.xAdvanceCell.value ?? this.#editor.font.defaultXAdvance, + }), + geometryShown, }); } @@ -134,6 +142,21 @@ export class BackgroundLayer extends CanvasItem { } } +function guideAdvance(input: GuideAdvanceInput): number { + return displayAdvance( + input.xAdvance, + input.record.name, + primaryUnicode(input.model, input.record), + ); +} + +function primaryUnicode(glyph: Glyph | null, record: GlyphRecord): Unicode | null { + const unicode = glyph?.unicode; + if (unicode !== null && unicode !== undefined && Number.isFinite(unicode)) return unicode; + + return record.unicodes[0] ?? null; +} + /** * Draws the active text run in scene space. * 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 cdcb5724..07f88219 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts @@ -68,7 +68,7 @@ export class Text { continue; } - const glyph = font.glyph({ name: g.glyphName }); + const glyph = g.glyphId ? font.glyphForId(g.glyphId) : null; if (!glyph) continue; const outline = glyph.instance(designLocation).render.outline; diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index fbfe45b5..cbc373b7 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -11,8 +11,8 @@ import { type Unicode, } from "@shift/types"; import type { WorkspaceSnapshot } from "@shared/workspace/protocol"; -import { signal } from "@/lib/signals/signal"; import { Font } from "./Font"; +import { FontStore } from "./FontStore"; import { createWorkspaceStack } from "@/testing/workspaceStack"; import { axisLocationFromLocation } from "@/lib/variation/location"; @@ -41,7 +41,7 @@ const SNAPSHOT: WorkspaceSnapshot = { describe("Font projects the workspace snapshot", () => { it("is unloaded with default metrics while no workspace is open", () => { - const font = new Font(signal(null)); + const font = new Font(new FontStore()); expect(font.loaded).toBe(false); expect(font.metrics.unitsPerEm).toBe(1000); @@ -49,10 +49,10 @@ describe("Font projects the workspace snapshot", () => { }); it("follows a snapshot: loaded, metrics, metadata, directory, sources", () => { - const $workspace = signal(null); - const font = new Font($workspace); + const store = new FontStore(); + const font = new Font(store); - $workspace.set(SNAPSHOT); + store.replaceWorkspace(SNAPSHOT); expect(font.loaded).toBe(true); expect(font.metrics.unitsPerEm).toBe(2048); @@ -65,23 +65,23 @@ describe("Font projects the workspace snapshot", () => { }); it("$loaded flips reactively when the snapshot changes", () => { - const $workspace = signal(null); - const font = new Font($workspace); + const store = new FontStore(); + const font = new Font(store); expect(font.$loaded.value).toBe(false); - $workspace.set(SNAPSHOT); + store.replaceWorkspace(SNAPSHOT); expect(font.$loaded.value).toBe(true); }); it("resets to the unloaded projection when the workspace goes null", () => { - const $workspace = signal(SNAPSHOT); - const font = new Font($workspace); + const store = new FontStore(SNAPSHOT); + const font = new Font(store); expect(font.loaded).toBe(true); - $workspace.set(null); + store.replaceWorkspace(null); expect(font.loaded).toBe(false); expect(font.metrics.unitsPerEm).toBe(1000); @@ -91,11 +91,11 @@ describe("Font projects the workspace snapshot", () => { }); it("an empty loaded font reports records, not the unloaded fallback", () => { - const $workspace = signal({ + const store = new FontStore({ ...SNAPSHOT, glyphs: [], }); - const font = new Font($workspace); + const font = new Font(store); expect(font.loaded).toBe(true); expect(font.glyphRecords()).toEqual([]); @@ -108,7 +108,7 @@ describe("font-level intents make the font variable", () => { const stack = createWorkspaceStack(); await stack.createWorkspace(); const glyphId = mintGlyphId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, @@ -117,7 +117,7 @@ describe("font-level intents make the font variable", () => { expect(stack.font.isVariable()).toBe(false); const weightAxisId = mintAxisId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createAxis", createAxis: { @@ -135,7 +135,7 @@ describe("font-level intents make the font variable", () => { expect(stack.font.isVariable()).toBe(true); const boldSourceId = mintSourceId(); - const applied = await stack.client.apply([ + const applied = await stack.editCoordinator.apply([ { kind: "createSource", createSource: { @@ -149,14 +149,14 @@ describe("font-level intents make the font variable", () => { expect(bold?.id).toBe(boldSourceId); expect(applied.sources?.find((source) => source.name === "Bold")?.id).toBe(boldSourceId); expect(applied.layers).toEqual([]); - expect(stack.font.glyphLayerRecord(glyphId, boldSourceId)).toBeNull(); + expect(stack.font.layerRecordForId(glyphId, boldSourceId)).toBeNull(); }); it("createGlyphLayer projects sparse glyph-layer membership", async () => { const stack = createWorkspaceStack(); await stack.createWorkspace(); const glyphId = mintGlyphId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, @@ -165,7 +165,7 @@ describe("font-level intents make the font variable", () => { const layerId = mintLayerId(); const sourceId = stack.font.defaultSource.id; - const applied = await stack.client.apply([ + const applied = await stack.editCoordinator.apply([ { kind: "createGlyphLayer", createGlyphLayer: { layerId, glyphId, sourceId }, @@ -173,7 +173,7 @@ describe("font-level intents make the font variable", () => { ]); expect(applied.glyphs?.[0]?.layers).toEqual([{ id: layerId, sourceId }]); - expect(stack.font.glyphLayerRecord(glyphId, sourceId)).toEqual({ id: layerId, sourceId }); + expect(stack.font.layerRecordForId(glyphId, sourceId)).toEqual({ id: layerId, sourceId }); }); it("createGlyph authors a default layer for fresh glyphs", async () => { @@ -190,18 +190,43 @@ describe("font-level intents make the font variable", () => { const committed = stack.font.recordForId(record.id); expect(committed?.layers).toEqual(record.layers); - expect(stack.font.glyphLayerRecord(record.id, source.id)).toEqual(record.layers[0]); + expect(stack.font.layerRecordForId(record.id, source.id)).toEqual(record.layers[0]); - const glyph = await stack.font.openGlyph(record.id, source); + const glyph = await stack.font.loadGlyph(record.id, { sourceIds: [source.id] }); expect(glyph?.xAdvance).toBe(stack.font.defaultXAdvance); }); + it("preserves glyph object identity while record names change", async () => { + const stack = createWorkspaceStack(); + await stack.createWorkspace(); + + const record = stack.font.createGlyph("A" as GlyphName); + await stack.editCoordinator.settled(); + const glyph = await stack.font.loadGlyph(record.id); + if (!glyph) throw new Error("Expected loaded glyph"); + + await stack.editCoordinator.apply([ + { + kind: "updateGlyph", + updateGlyph: { + glyphId: record.id, + newName: "A.alt" as GlyphName, + newUnicodes: [0xe001 as Unicode], + }, + }, + ]); + + expect(stack.font.glyphForId(record.id)).toBe(glyph); + expect(glyph.name).toBe("A.alt"); + expect(glyph.unicode).toBe(0xe001); + }); + it("exact sources without glyph layers have no live layer and do not render default geometry", async () => { const stack = createWorkspaceStack(); await stack.createWorkspace(); const glyphId = mintGlyphId(); const defaultLayerId = mintLayerId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, @@ -218,7 +243,7 @@ describe("font-level intents make the font variable", () => { ]); const axisId = mintAxisId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createAxis", createAxis: { @@ -233,7 +258,7 @@ describe("font-level intents make the font variable", () => { }, ]); const sourceId = mintSourceId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createSource", createSource: { @@ -244,7 +269,9 @@ describe("font-level intents make the font variable", () => { }, ]); - const glyph = await stack.font.openGlyph(glyphId, stack.font.defaultSource); + const glyph = await stack.font.loadGlyph(glyphId, { + sourceIds: [stack.font.defaultSource.id], + }); if (!glyph) throw new Error("Expected default glyph layer to open"); expect(glyph.xAdvance).toBe(640); @@ -257,4 +284,65 @@ describe("font-level intents make the font variable", () => { expect(instance.xAdvance).toBe(0); expect(instance.geometry.allPoints).toEqual([]); }); + + it("does not drop concurrent snapshot loads for different sources on one glyph", async () => { + const stack = createWorkspaceStack(); + await stack.createWorkspace(); + const glyphId = mintGlyphId(); + const defaultSourceId = stack.font.defaultSource.id; + const defaultLayerId = mintLayerId(); + await stack.editCoordinator.apply([ + { + kind: "createGlyph", + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, + }, + { + kind: "createGlyphLayer", + createGlyphLayer: { layerId: defaultLayerId, glyphId, sourceId: defaultSourceId }, + }, + ]); + + const axisId = mintAxisId(); + await stack.editCoordinator.apply([ + { + kind: "createAxis", + createAxis: { + axisId, + tag: "wght", + name: "Weight", + min: 100, + default: 400, + max: 900, + hidden: false, + }, + }, + ]); + const boldSourceId = mintSourceId(); + const boldLayerId = mintLayerId(); + await stack.editCoordinator.apply([ + { + kind: "createSource", + createSource: { + sourceId: boldSourceId, + name: "Bold", + location: { values: { [axisId]: 700 } as Record }, + }, + }, + { + kind: "createGlyphLayer", + createGlyphLayer: { layerId: boldLayerId, glyphId, sourceId: boldSourceId }, + }, + ]); + + const regularSource = stack.font.source(defaultSourceId); + const boldSource = stack.font.source(boldSourceId); + if (!regularSource || !boldSource) throw new Error("Expected both sources"); + + const regularLoad = stack.font.loadGlyph(glyphId, { sourceIds: [defaultSourceId] }); + const boldLoad = stack.font.loadGlyph(glyphId, { sourceIds: [boldSourceId] }); + await Promise.all([regularLoad, boldLoad]); + + expect(stack.font.glyphLayerForId(glyphId, regularSource.id)?.id).toBe(defaultLayerId); + expect(stack.font.glyphLayerForId(glyphId, boldSource.id)?.id).toBe(boldLayerId); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index be1d5e54..e55681c9 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -6,8 +6,8 @@ import type { GlyphId, GlyphRecord, GlyphLayerRecord, - GlyphState, GlyphName, + GlyphVariationData, SourceId, Unicode, AxisId, @@ -17,9 +17,11 @@ import type { import { mintAxisId, mintGlyphId, mintLayerId, mintSourceId } from "@shift/types"; import { computed, type Signal } from "@/lib/signals/signal"; import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; -import type { WorkspaceSnapshot } from "@shared/workspace/protocol"; +import type { WorkspaceGlyphSnapshotRequest } from "@shared/workspace/protocol"; import { Glyph, type GlyphLayer } from "./Glyph"; import { GlyphOutline } from "./GlyphOutline"; +import type { FontStore, GlyphSnapshotStatus } from "./FontStore"; +import type { GlyphLayerState } from "./GlyphLayerState"; import type { GlyphHandle } from "@shift/bridge"; import { axisLocationDistanceSquared, @@ -32,6 +34,17 @@ import type { AxisLocation } from "@/types/variation"; import { defaultResources, GlyphInfo } from "@shift/glyph-info"; import { fallbackGlyphNameForUnicode } from "../utils/unicode"; +export type GlyphLoadOptions = { + readonly sourceIds?: readonly SourceId[]; +}; + +type SnapshotRequest = { + glyphId: GlyphId; + sourceIds: SourceId[]; +}; + +type InFlightKey = string & { readonly __inFlightKey: unique symbol }; + /** * Immutable lookup index for committed glyph records. * @@ -263,8 +276,6 @@ class GlyphDirectory { } } -type GlyphLayerKey = string & { readonly __glyphLayerKey: unique symbol }; - const DEFAULT_FONT_METRICS: FontMetrics = { unitsPerEm: 1000, ascender: 800, @@ -274,12 +285,12 @@ const DEFAULT_FONT_METRICS: FontMetrics = { }; /** - * Reactive domain model for the loaded font. + * Reactive facade for the loaded font. * - * `Font` owns font-level metadata, source lookup, glyph identity lookup, and - * cached glyph models. Getters such as `metrics`, `unicodes`, and `sources` - * are signal-backed: reading them inside a computed or effect subscribes to - * later font loads/resets. + * `Font` exposes font-level metadata, source lookup, and domain editing verbs + * over `FontStore`. Getters such as `metrics`, `unicodes`, and `sources` are + * signal-backed: reading them inside a computed or effect subscribes to later + * font loads/resets. * * A glyph handle is only an identity. It may name a glyph that is not committed * in the font yet. Use {@link glyph} for existing glyph data, and use the @@ -296,33 +307,27 @@ export class Font { readonly #$glyphRecords: Signal; readonly #directory: Signal; - readonly #glyphs = new Map(); - /** Open glyph models keyed by stable id; survives directory re-keys. */ - readonly #glyphsById = new Map(); - readonly #glyphLayers = new Map(); + readonly #store: FontStore; readonly #editCoordinator: WorkspaceEditCoordinator | null; - #cachesKeyedTo: GlyphDirectory | null = null; + readonly #glyphLoadsInFlight = new Map>(); /** * Projects the renderer's workspace snapshot into the font domain model. * - * @param $workspace - Single source of workspace truth owned by - * `WorkspaceClient`. There is no load: every derived value follows this - * signal, and `null` means no font is open. - * @param editCoordinator - Optional queue used by authored layer projections to submit + * @param store - Renderer-local owner of committed records and loaded glyph snapshots. + * @param editCoordinator - Optional sync lane used by authored layer projections to submit * committed edits to the utility workspace. */ - constructor( - $workspace: Signal, - editCoordinator?: WorkspaceEditCoordinator, - ) { + constructor(store: FontStore, editCoordinator?: WorkspaceEditCoordinator) { + this.#store = store; this.#editCoordinator = editCoordinator ?? null; - this.#$loaded = computed(() => $workspace.value !== null); - this.#$metrics = computed(() => $workspace.value?.metrics ?? DEFAULT_FONT_METRICS); - this.#$metadata = computed(() => $workspace.value?.metadata ?? {}); - this.#$sources = computed(() => $workspace.value?.sources ?? []); - this.#$axes = computed(() => $workspace.value?.axes ?? []); - this.#directory = computed(() => GlyphDirectory.fromRecords($workspace.value?.glyphs ?? [])); + const workspaceCell = store.workspaceCell; + this.#$loaded = computed(() => workspaceCell.value !== null); + this.#$metrics = computed(() => workspaceCell.value?.metrics ?? DEFAULT_FONT_METRICS); + this.#$metadata = computed(() => workspaceCell.value?.metadata ?? {}); + this.#$sources = computed(() => workspaceCell.value?.sources ?? []); + this.#$axes = computed(() => workspaceCell.value?.axes ?? []); + this.#directory = computed(() => GlyphDirectory.fromRecords(workspaceCell.value?.glyphs ?? [])); this.#$unicodes = computed(() => [...this.#directory.value.unicodes]); this.#$glyphRecords = computed(() => this.#directory.value.records); } @@ -377,6 +382,11 @@ export class Font { return this.#$glyphRecords; } + /** Reactive glyph snapshot freshness for model consumers that derive loaded glyph views. */ + get glyphSnapshotStatusCell(): Signal> { + return this.#store.snapshotStatusCell; + } + /** @knipclassignore */ get metadata(): FontMetadata { return this.#$metadata.peek(); @@ -413,7 +423,7 @@ export class Font { * @knipclassignore */ hasGlyph(glyphId: GlyphId): boolean { - return this.#directory.peek().hasGlyph(glyphId); + return this.#store.hasGlyph(glyphId); } /** @@ -429,12 +439,7 @@ export class Font { /** Returns the committed glyph record for a stable glyph id. */ recordForId(glyphId: GlyphId): GlyphRecord | null { - return this.#directory.peek().recordForId(glyphId); - } - - /** Returns the committed layer record for an exact glyph/source pair. */ - layerForGlyphAtSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerRecord | null { - return this.#directory.peek().layerForGlyphAtSource(glyphId, sourceId); + return this.#store.recordForId(glyphId); } /** @@ -575,8 +580,8 @@ export class Font { * @param sourceId - Source whose authored glyph data is requested. * @returns The sparse layer record, or `null` when that source has no layer for the glyph. */ - glyphLayerRecord(glyphId: GlyphId, sourceId: SourceId): GlyphLayerRecord | null { - return this.#directory.peek().layerForGlyphAtSource(glyphId, sourceId); + layerRecordForId(glyphId: GlyphId, sourceId: SourceId): GlyphLayerRecord | null { + return this.#store.layerRecordForId(glyphId, sourceId); } /** @@ -616,85 +621,220 @@ export class Font { } /** - * Get the cached model for an existing glyph. - * - * This is a read/data access API. It asks the bridge for glyph state at the - * font's default source and returns `null` when no state exists. It does not - * create missing glyphs or select a layer source. - * - * @example - * ```ts - * const glyph = font.glyph(handle) - * const outline = glyph?.outlineAt(location) - * ``` + * Returns the local glyph model for a stable glyph id. * - * @returns The glyph model, or `null` when the glyph has no state for the default source. + * @param glyphId - document glyph identity to resolve. + * @returns the id-keyed glyph model, or null when the glyph is missing or not loaded. */ - glyph(handle: GlyphHandle): Glyph | null { + glyphForId(glyphId: GlyphId): Glyph | null { if (!this.loaded) return null; - this.#syncCaches(); + const directory = this.#directory.peek(); + const record = this.#store.recordForId(glyphId); + if (!record) return null; - const cached = this.#glyphs.get(handle.name); - if (cached) return cached; + return this.#store.glyphModel(glyphId, () => { + const source = this.defaultSource; + const layer = directory.layerForGlyphAtSource(glyphId, source.id); + if (!layer) return null; - // Open models survive directory re-keys under their stable id; re-link - // the name cache after #syncCaches cleared it. - const directory = this.#directory.peek(); - const record = directory.recordForName(handle.name); - const openModel = record ? this.#glyphsById.get(record.id) : undefined; - if (openModel) { - this.#glyphs.set(handle.name, openModel); - return openModel; - } + const state = this.layerState(layer.id); + if (!state) return null; + + return new Glyph(this, glyphId, directory.glyphHandleForName(record.name), source, state); + }); + } - const source = this.defaultSource; - const layer = record ? directory.layerForGlyphAtSource(record.id, source.id) : null; + /** + * Returns local authored layer data for an exact glyph/source pair. + * + * @param glyphId - document glyph identity to resolve. + * @param sourceId - exact source whose authored layer should be loaded. + * @returns the id-keyed glyph layer model, or null when record or geometry is unavailable. + */ + glyphLayerForId(glyphId: GlyphId, sourceId: SourceId): GlyphLayer | null { + const source = this.source(sourceId); + if (!source) return null; + + const layer = this.#store.layerRecordForId(glyphId, source.id); if (!layer) return null; - const state = this.layerState(layer.id); - if (!state) return null; + return this.#store.glyphLayerModel(glyphId, source.id, () => { + const glyph = this.glyphForId(glyphId); + if (!glyph) return null; - const glyph = new Glyph(this, handle, source, state); - this.#glyphs.set(handle.name, glyph); - return glyph; + const state = glyph.isPrimarySource(source) ? undefined : this.layerState(layer.id); + return glyph.createGlyphLayer(source, state); + }); } /** - * Get authored layer data for a glyph at an exact source. + * Reports whether every authored source for each glyph has local geometry. * - * A source is a designspace location. This method returns the authored glyph - * layer model when both the source and glyph layer state exist. - * It does not choose a fallback source and does not create missing glyph data. + * @param glyphIds - Stable glyph identities to inspect. + */ + areGlyphsLoaded(glyphIds: readonly GlyphId[]): boolean { + for (const glyphId of glyphIds) { + if (!this.recordForId(glyphId)) return false; + for (const sourceId of this.#store.sourceIdsForGlyph(glyphId)) { + if (this.#store.needsGlyphSource(glyphId, sourceId)) return false; + } + } + return true; + } + + /** + * Loads one glyph's geometry and returns its live model. * - * @example - * ```ts - * const source = font.defaultSource - * const glyphLayer = font.glyphLayer(handle, source) - * ``` + * @param glyphId - Stable glyph identity whose local geometry should be available. + * @param options - Optional source scope; omitted means every authored layer for the glyph. + * @returns null when the glyph is not present in the current font. + * @see {@link loadGlyphs} + */ + async loadGlyph(glyphId: GlyphId, options: GlyphLoadOptions = {}): Promise { + return (await this.loadGlyphs([glyphId], options)).get(glyphId) ?? null; + } + + /** + * Loads missing or stale glyph geometry and returns the requested live models. * - * @returns The authored glyph layer, or `null` when the source or layer state is unavailable. + * @remarks + * Component bases discovered while loading are hydrated as a side effect, but + * the returned map is keyed only by the glyph IDs requested by the caller. + * + * @param glyphIds - Stable glyph identities whose local geometry should be available. + * @param options - Optional source scope; omitted means every authored layer for each glyph. + * @returns A fresh map containing the requested glyphs that exist in the current font. */ - glyphLayer(handle: GlyphHandle, source: Source): GlyphLayer | null { - if (!this.source(source.id)) return null; + async loadGlyphs( + glyphIds: readonly GlyphId[], + options: GlyphLoadOptions = {}, + ): Promise> { + await this.#loadGlyphSnapshots(glyphIds, options); + + const glyphs = new Map(); + for (const glyphId of uniqueGlyphIds(glyphIds)) { + const glyph = this.glyphForId(glyphId); + if (glyph) glyphs.set(glyphId, glyph); + } + return glyphs; + } - this.#syncCaches(); - const layer = this.#directory.peek().layerForGlyphNameAtSource(handle.name, source.id); - if (!layer) return null; + async #loadGlyphSnapshots( + glyphIds: readonly GlyphId[], + options: GlyphLoadOptions = {}, + ): Promise { + if (!this.#editCoordinator || glyphIds.length === 0) return; + + await this.#editCoordinator.settled(); + + const queue = uniqueGlyphIds(glyphIds); + const seen = new Set(queue); + + while (queue.length > 0) { + const batchGlyphIds = queue.splice(0); + const inFlight = this.#inFlightLoadsFor(batchGlyphIds, options); + if (inFlight.length > 0) { + await Promise.all(inFlight); + } + + const requests = this.#requestableGlyphs(batchGlyphIds, options); + if (requests.length > 0) { + await this.#loadGlyphRequests(requests); + } + + for (const glyphId of batchGlyphIds) { + for (const baseGlyphId of this.#store.loadedComponentBaseGlyphIds(glyphId)) { + if (seen.has(baseGlyphId)) continue; + seen.add(baseGlyphId); + queue.push(baseGlyphId); + } + } + } + } + + #requestableGlyphs( + glyphIds: readonly GlyphId[], + options: GlyphLoadOptions, + ): WorkspaceGlyphSnapshotRequest[] { + const result: SnapshotRequest[] = []; + const seen = new Set(); + for (const glyphId of glyphIds) { + if (seen.has(glyphId)) continue; + const sourceIds = this.#neededSourceIds(glyphId, options); + if (sourceIds.length === 0) continue; + seen.add(glyphId); + result.push({ glyphId, sourceIds }); + } + return result; + } + + #neededSourceIds(glyphId: GlyphId, options: GlyphLoadOptions): SourceId[] { + const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); + const needed: SourceId[] = []; + const seen = new Set(); + + for (const sourceId of sourceIds) { + if (seen.has(sourceId)) continue; + seen.add(sourceId); + if (this.#store.needsGlyphSource(glyphId, sourceId)) needed.push(sourceId); + } - const key = glyphLayerModelKey(handle.name, source.id); - const cached = this.#glyphLayers.get(key); - if (cached) return cached; + return needed; + } + + #inFlightLoadsFor(glyphIds: readonly GlyphId[], options: GlyphLoadOptions): Promise[] { + const promises: Promise[] = []; + const seen = new Set>(); + + for (const glyphId of uniqueGlyphIds(glyphIds)) { + const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); + for (const sourceId of sourceIds) { + const promise = this.#glyphLoadsInFlight.get(inFlightKey(glyphId, sourceId)); + if (!promise || seen.has(promise)) continue; + seen.add(promise); + promises.push(promise); + } + } + + return promises; + } + + async #loadGlyphRequests(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { + const promise = this.#readAndApplyGlyphRequests(requests); + + for (const request of requests) { + for (const sourceId of request.sourceIds) { + this.#glyphLoadsInFlight.set(inFlightKey(request.glyphId, sourceId), promise); + } + } - const glyph = this.glyph(handle); - if (!glyph) return null; + try { + await promise; + } finally { + for (const request of requests) { + for (const sourceId of request.sourceIds) { + const key = inFlightKey(request.glyphId, sourceId); + if (this.#glyphLoadsInFlight.get(key) === promise) { + this.#glyphLoadsInFlight.delete(key); + } + } + } + } + } - const state = glyph.isPrimarySource(source) ? undefined : this.layerState(layer.id); - const glyphLayer = glyph.createGlyphLayer(source, state); - if (!glyphLayer) return null; + async #readAndApplyGlyphRequests( + requests: readonly WorkspaceGlyphSnapshotRequest[], + ): Promise { + if (!this.#editCoordinator) return; - this.#glyphLayers.set(key, glyphLayer); - return glyphLayer; + const load = this.#store.beginGlyphLoad(requests); + try { + this.#store.finishGlyphLoad(load, await this.#editCoordinator.readGlyphSnapshots(requests)); + } catch (error) { + this.#store.failGlyphLoad(load); + throw error; + } } /** @@ -710,15 +850,17 @@ export class Font { } /** - * Read raw glyph state for an authored layer from the bridge. + * Returns the store-owned layer state for loaded glyph geometry. * - * @param layerId - Stable layer identity to read. - * @returns Raw glyph state, or `null` when the bridge cannot provide state. + * @param layerId - stable authored layer identity to resolve. + * @returns the loaded layer state, or null when its glyph snapshot is not loaded. */ - layerState(_layerId: LayerId): GlyphState | null { - // Geometry is not part of the workspace snapshot yet; "no state" is the - // honest answer for every layer until change sets land. - return null; + layerState(layerId: LayerId): GlyphLayerState | null { + return this.#store.layerState(layerId); + } + + variationData(glyphId: GlyphId): GlyphVariationData | null { + return this.#store.variationData(glyphId); } /** @@ -832,67 +974,6 @@ export class Font { return this.#editCoordinator; } - /** - * Opens (or returns the cached) glyph model, pulling - * replace-grade state from the workspace. - * - * @remarks - * Models are cached by stable GlyphId, so open sessions survive directory - * re-keys and renames. Returns null when the glyph or layer is missing. - */ - async openGlyph(glyphId: GlyphId, source: Source): Promise { - const directory = this.#directory.peek(); - const record = directory.recordForId(glyphId); - if (!record) return null; - - const layer = directory.layerForGlyphAtSource(glyphId, source.id); - if (!layer) return null; - - const cached = this.#glyphsById.get(glyphId); - if (cached) return cached; - - const state = await this.editCoordinator.layer(layer.id); - if (!state) return null; - - const handle = directory.glyphHandleForName(record.name); - const glyph = new Glyph(this, handle, source, state, glyphId); - - this.#glyphsById.set(glyphId, glyph); - this.#glyphs.set(record.name, glyph); - return glyph; - } - - /** - * Opens (or returns the cached) authored glyph layer at any source, - * pulling replace-grade state from the workspace. - * - * @remarks - * This is the async entry point for non-primary sources; once opened, the - * sync {@link glyphLayer} resolves from cache (instance resolution, - * tools). Echoes for the source's layer fold into the opened state. - */ - async openGlyphLayer(glyphId: GlyphId, source: Source): Promise { - this.#syncCaches(); - const layer = this.#directory.peek().layerForGlyphAtSource(glyphId, source.id); - if (!layer) return null; - - const glyph = - this.#glyphsById.get(glyphId) ?? (await this.openGlyph(glyphId, this.defaultSource)); - if (!glyph) return null; - - const cached = this.glyphLayer(glyph.handle, source); - if (cached) return cached; - - const state = await this.editCoordinator.layer(layer.id); - if (!state) return null; - - const glyphLayer = glyph.createGlyphLayer(source, state); - if (!glyphLayer) return null; - - this.#glyphLayers.set(glyphLayerModelKey(glyph.handle.name, source.id), glyphLayer); - return glyphLayer; - } - createSource(name: string, location: Location): SourceId { const sourceId = mintSourceId(); this.editCoordinator.push({ @@ -946,25 +1027,19 @@ export class Font { return this.#$sources.peek(); } - /** Drops cached glyph models when the directory they were built from changes. */ - #syncCaches(): void { - const directory = this.#directory.peek(); - if (this.#cachesKeyedTo === directory) return; - - this.#glyphs.clear(); - this.#glyphLayers.clear(); - this.#cachesKeyedTo = directory; - } - defaultLocation(): AxisLocation { return this.isVariable() ? defaultAxisLocation(this.getAxes()) : emptyAxisLocation(); } } -function glyphLayerModelKey(name: GlyphName, sourceId: SourceId): GlyphLayerKey { - return `${sourceId}:${name}` as GlyphLayerKey; -} - function glyphLayerKey(glyphId: GlyphId, sourceId: SourceId): string { return `${glyphId}:${sourceId}`; } + +function inFlightKey(glyphId: GlyphId, sourceId: SourceId): InFlightKey { + return `${glyphId}:${sourceId}` as InFlightKey; +} + +function uniqueGlyphIds(glyphIds: readonly GlyphId[]): GlyphId[] { + return [...new Set(glyphIds)]; +} diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts new file mode 100644 index 00000000..a5295767 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { GlyphId, GlyphName, GlyphState, LayerId, SourceId, Unicode } from "@shift/types"; +import type { WorkspaceGlyphSnapshot, WorkspaceSnapshot } from "@shared/workspace/protocol"; +import { FontStore } from "./FontStore"; + +const GLYPH_ID = "glyph_shared" as GlyphId; +const SOURCE_ID = "source_regular" as SourceId; +const LAYER_A_ID = "layer_a" as LayerId; +const LAYER_B_ID = "layer_b" as LayerId; + +describe("FontStore snapshot freshness", () => { + it("ignores stale snapshot responses after a workspace replacement", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); + + store.replaceWorkspace(snapshot("document-b", LAYER_B_ID)); + store.finishGlyphLoad(load, [glyphSnapshot(LAYER_A_ID)]); + + expect(store.snapshotStatus(GLYPH_ID)).toBe("missing"); + expect(store.layerState(LAYER_A_ID)).toBeNull(); + expect(store.layerState(LAYER_B_ID)).toBeNull(); + }); + + it("marks snapshot request failures against the matching workspace generation", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); + + store.failGlyphLoad(load); + + expect(store.snapshotStatus(GLYPH_ID)).toBe("failed"); + }); +}); + +function snapshot(documentId: string, layerId: LayerId): WorkspaceSnapshot { + return { + documentId, + metadata: { familyName: "Untitled Font" }, + metrics: { unitsPerEm: 1000, ascender: 800, descender: -200 }, + glyphs: [ + { + id: GLYPH_ID, + name: "A" as GlyphName, + unicodes: [65 as Unicode], + componentBaseGlyphIds: [], + layers: [{ id: layerId, sourceId: SOURCE_ID }], + }, + ], + sources: [ + { + id: SOURCE_ID, + name: "Regular", + location: { values: {} }, + }, + ], + axes: [], + }; +} + +function glyphSnapshot(layerId: LayerId): WorkspaceGlyphSnapshot { + return { + glyphId: GLYPH_ID, + layers: [ + { + glyphId: GLYPH_ID, + sourceId: SOURCE_ID, + state: glyphState(layerId), + }, + ], + }; +} + +function glyphState(layerId: LayerId): GlyphState { + return { + layerId, + structure: { contours: [], anchors: [], components: [] }, + values: new Float64Array([600]), + }; +} diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts new file mode 100644 index 00000000..ad4c3d1a --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -0,0 +1,337 @@ +import type { + AppliedChange, + GlyphId, + GlyphLayerRecord, + GlyphRecord, + GlyphStructure, + GlyphState, + GlyphVariationData, + LayerId, + SourceId, +} from "@shift/types"; +import type { + WorkspaceGlyphLayerSnapshot, + WorkspaceGlyphSnapshotRequest, + WorkspaceGlyphSnapshot, + WorkspaceSnapshot, +} from "@shared/workspace/protocol"; +import { batch, signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; +import { GlyphLayerState } from "./GlyphLayerState"; +import type { Glyph, GlyphLayer } from "./Glyph"; + +export type GlyphSnapshotStatus = "missing" | "loading" | "loaded" | "stale" | "failed"; +export type WorkspaceCommitState = "idle" | "queued" | "applying"; + +type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; + +const EMPTY_STATUS: ReadonlyMap = new Map(); + +export interface GlyphLoadBatch { + readonly generation: number; + readonly glyphIds: readonly GlyphId[]; +} + +/** + * Renderer-local owner for font records, loaded glyph snapshots, model caches, + * and snapshot status. + * + * Model reads and snapshot freshness live directly on the store. Workspace + * mutation and async read ordering are owned by `WorkspaceEditCoordinator`; glyph-load + * request selection is owned by `Font`. + */ +export class FontStore { + readonly #workspace: WritableSignal; + readonly #snapshotStatus: WritableSignal>; + + readonly #layerStates = new Map(); + readonly #layerByGlyphSource = new Map(); + readonly #glyphByLayer = new Map(); + readonly #glyphById = new Map(); + readonly #glyphModels = new Map(); + readonly #glyphLayerModels = new Map(); + readonly #variationDataByGlyph = new Map(); + readonly #snapshotGeneration = new Map(); + + #generation = 0; + + constructor(workspace: WorkspaceSnapshot | null = null) { + this.#workspace = signal(workspace, { name: "fontStore.workspace" }); + this.#snapshotStatus = signal(EMPTY_STATUS, { name: "fontStore.snapshotStatus" }); + if (workspace) this.#indexWorkspace(workspace); + } + + get workspaceCell(): Signal { + return this.#workspace; + } + + get snapshotStatusCell(): Signal> { + return this.#snapshotStatus; + } + + replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { + batch(() => { + this.#generation += 1; + this.#workspace.set(snapshot); + this.#indexWorkspace(snapshot); + this.#layerStates.clear(); + this.#glyphModels.clear(); + this.#glyphLayerModels.clear(); + this.#variationDataByGlyph.clear(); + this.#snapshotGeneration.clear(); + this.#snapshotStatus.set(EMPTY_STATUS); + }); + } + + beginGlyphLoad(requests: readonly WorkspaceGlyphSnapshotRequest[]): GlyphLoadBatch { + const generation = this.#generation; + const next = new Map(this.#snapshotStatus.peek()); + const glyphIds = requests.map((request) => request.glyphId); + for (const glyphId of glyphIds) { + this.#snapshotGeneration.set(glyphId, generation); + next.set(glyphId, "loading"); + } + this.#snapshotStatus.set(next); + return { generation, glyphIds }; + } + + failGlyphLoad(batch: GlyphLoadBatch): void { + if (batch.generation !== this.#generation) return; + + const next = new Map(this.#snapshotStatus.peek()); + for (const glyphId of batch.glyphIds) { + if (this.#snapshotGeneration.get(glyphId) === batch.generation) { + next.set(glyphId, "failed"); + } + } + this.#snapshotStatus.set(next); + } + + finishGlyphLoad(load: GlyphLoadBatch, snapshots: readonly WorkspaceGlyphSnapshot[]): void { + if (load.generation !== this.#generation) return; + + const received = new Set(snapshots.map((snapshot) => snapshot.glyphId)); + const nextStatus = new Map(this.#snapshotStatus.peek()); + + batch(() => { + for (const snapshot of snapshots) { + if (this.#snapshotGeneration.get(snapshot.glyphId) !== load.generation) continue; + + if (snapshot.variationData) { + this.#variationDataByGlyph.set(snapshot.glyphId, snapshot.variationData); + } else { + this.#variationDataByGlyph.delete(snapshot.glyphId); + } + + for (const layer of snapshot.layers) { + this.#applyLayerSnapshot(layer); + } + nextStatus.set(snapshot.glyphId, "loaded"); + } + + for (const glyphId of load.glyphIds) { + if (!received.has(glyphId) && this.#snapshotGeneration.get(glyphId) === load.generation) { + nextStatus.set(glyphId, "missing"); + } + } + + this.#snapshotStatus.set(nextStatus); + }); + } + + applyWorkspaceChange(applied: AppliedChange): void { + const current = this.#workspace.peek(); + if (!current) return; + + batch(() => { + const nextWorkspace = + applied.glyphs || applied.axes || applied.sources + ? { + ...current, + glyphs: applied.glyphs ?? current.glyphs, + axes: applied.axes ?? current.axes, + sources: applied.sources ?? current.sources, + } + : current; + + if (nextWorkspace !== current) { + this.#generation += 1; + this.#workspace.set(nextWorkspace); + this.#indexWorkspace(nextWorkspace); + } + + const staleGlyphIds = new Set(applied.dependents); + for (const layer of applied.layers) { + const glyphId = this.#glyphByLayer.get(layer.layerId); + if (glyphId) staleGlyphIds.add(glyphId); + + const state = this.#layerStates.get(layer.layerId); + if (!state) continue; + + if (layer.structure) { + state.replace({ + layerId: layer.layerId, + structure: layer.structure, + values: layer.values, + }); + } else { + state.replaceValues(layer.values); + } + } + + if (applied.axes || applied.sources) { + for (const glyphId of this.#snapshotStatus.peek().keys()) { + staleGlyphIds.add(glyphId); + } + } + + this.#markLoadedSnapshotsStale(staleGlyphIds); + }); + } + + layerState(layerId: LayerId): GlyphLayerState | null { + return this.#layerStates.get(layerId) ?? null; + } + + hasGlyph(glyphId: GlyphId): boolean { + return this.#glyphById.has(glyphId); + } + + recordForId(glyphId: GlyphId): GlyphRecord | null { + return this.#glyphById.get(glyphId) ?? null; + } + + layerRecordForId(glyphId: GlyphId, sourceId: SourceId): GlyphLayerRecord | null { + return this.recordForId(glyphId)?.layers.find((layer) => layer.sourceId === sourceId) ?? null; + } + + variationData(glyphId: GlyphId): GlyphVariationData | null { + return this.#variationDataByGlyph.get(glyphId) ?? null; + } + + glyphModel(glyphId: GlyphId, create: () => Glyph | null): Glyph | null { + const cached = this.#glyphModels.get(glyphId); + if (cached) return cached; + + const created = create(); + if (created) this.#glyphModels.set(glyphId, created); + return created; + } + + glyphLayerModel( + glyphId: GlyphId, + sourceId: SourceId, + create: () => GlyphLayer | null, + ): GlyphLayer | null { + const key = glyphSourceKey(glyphId, sourceId); + const cached = this.#glyphLayerModels.get(key); + if (cached) return cached; + + const created = create(); + if (created) this.#glyphLayerModels.set(key, created); + return created; + } + + sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { + return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; + } + + snapshotStatus(glyphId: GlyphId): GlyphSnapshotStatus { + return this.#snapshotStatus.peek().get(glyphId) ?? "missing"; + } + + hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean { + return this.#layerStateForGlyphSource(glyphId, sourceId) !== null; + } + + hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean { + return this.#layerByGlyphSource.has(glyphSourceKey(glyphId, sourceId)); + } + + needsGlyphSource(glyphId: GlyphId, sourceId: SourceId): boolean { + if (!this.hasLayerRecord(glyphId, sourceId)) return false; + const status = this.snapshotStatus(glyphId); + return status === "stale" || status === "failed" || !this.hasLayerSnapshot(glyphId, sourceId); + } + + loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { + const baseGlyphIds = new Set(); + for (const state of this.#loadedLayerStatesForGlyph(glyphId)) { + for (const baseGlyphId of componentBaseGlyphIds(state.structure)) { + baseGlyphIds.add(baseGlyphId); + } + } + return [...baseGlyphIds]; + } + + #layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { + const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); + return layerId ? (this.#layerStates.get(layerId) ?? null) : null; + } + + #applyLayerSnapshot(snapshot: WorkspaceGlyphLayerSnapshot): void { + this.#glyphByLayer.set(snapshot.state.layerId, snapshot.glyphId); + this.#layerByGlyphSource.set( + glyphSourceKey(snapshot.glyphId, snapshot.sourceId), + snapshot.state.layerId, + ); + this.#replaceLayerState(snapshot.state); + } + + #replaceLayerState(state: GlyphState): GlyphLayerState { + const existing = this.#layerStates.get(state.layerId); + if (existing) { + existing.replace(state); + return existing; + } + + const created = new GlyphLayerState(state); + this.#layerStates.set(state.layerId, created); + return created; + } + + #loadedLayerStatesForGlyph(glyphId: GlyphId): GlyphLayerState[] { + const workspace = this.#workspace.peek(); + const record = workspace?.glyphs.find((candidate) => candidate.id === glyphId); + if (!record) return []; + + return record.layers + .map((layer) => this.#layerStates.get(layer.id)) + .filter((state): state is GlyphLayerState => state !== undefined); + } + + #markLoadedSnapshotsStale(glyphIds: Iterable): void { + const nextStatus = new Map(this.#snapshotStatus.peek()); + let changed = false; + + for (const glyphId of glyphIds) { + if (nextStatus.get(glyphId) !== "loaded") continue; + nextStatus.set(glyphId, "stale"); + changed = true; + } + + if (changed) this.#snapshotStatus.set(nextStatus); + } + + #indexWorkspace(snapshot: WorkspaceSnapshot | null): void { + this.#layerByGlyphSource.clear(); + this.#glyphByLayer.clear(); + this.#glyphById.clear(); + if (!snapshot) return; + + for (const glyph of snapshot.glyphs) { + this.#glyphById.set(glyph.id, glyph); + for (const layer of glyph.layers) { + this.#layerByGlyphSource.set(glyphSourceKey(glyph.id, layer.sourceId), layer.id); + this.#glyphByLayer.set(layer.id, glyph.id); + } + } + } +} + +function glyphSourceKey(glyphId: GlyphId, sourceId: SourceId): GlyphSourceKey { + return `${glyphId}:${sourceId}` as GlyphSourceKey; +} + +function componentBaseGlyphIds(structure: GlyphStructure): readonly GlyphId[] { + return structure.components.map((component) => component.baseGlyphId); +} 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 ec5eea7a..1b4d4938 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { PointId } from "@shift/types"; +import type { GlyphName, PointId } from "@shift/types"; import type { Point } from "@shift/glyph-state"; import { effect } from "@/lib/signals/signal"; import { axisLocationFromLocation } from "@/lib/variation/location"; @@ -51,7 +51,8 @@ describe("Glyph", () => { editor = new TestEditor(); await editor.startSession(); layer = editor.editingGlyphLayer!; - glyph = editor.font.glyph(editor.rootGlyphHandle!)!; + const record = editor.font.recordForName("A" as GlyphName)!; + glyph = editor.font.glyphForId(record.id)!; }); it("hydrates identity and state from the workspace", () => { @@ -180,7 +181,8 @@ describe("glyph layers keep public geometry coherent across position edits", () editor = new TestEditor(); await editor.startSession(); layer = editor.editingGlyphLayer!; - glyph = editor.font.glyph(editor.rootGlyphHandle!)!; + const record = editor.font.recordForName("A" as GlyphName)!; + glyph = editor.font.glyphForId(record.id)!; }); it("previews point patches through every public layer geometry view", async () => { diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index fe816fde..379cc9df 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -6,11 +6,11 @@ import type { GlyphName, GlyphState, GlyphStructure, - GlyphVariationData, LayerId, PointId, PointSeed, Source, + SourceId, Unicode, } from "@shift/types"; import { mintAnchorId, mintContourId, mintPointId } from "@shift/types"; @@ -155,7 +155,7 @@ class GlyphEditSession { } } - // Mixed patches push two intents in the same tick; the editCoordinator coalesces + // Mixed patches push two intents in the same tick; the edit coordinator coalesces // them into one apply and therefore one undo step. if (pointIds.length > 0) { this.#intents.movePoints({ pointIds, coords: pointCoords }); @@ -291,13 +291,17 @@ export class GlyphLayer { this.#edit = edit; } - /** @knipclassignore — convenience alias for source identity. */ - get id() { - return this.source.id; + /** @knipclassignore — stable edit identity for this authored glyph layer. */ + get id(): LayerId { + return this.#edit.layerState.state.layerId; + } + + get layerId(): LayerId { + return this.id; } /** @knipclassignore — convenience alias for source identity. */ - get sourceId() { + get sourceId(): SourceId { return this.source.id; } @@ -1315,14 +1319,12 @@ class ContourCache { * location. */ export class Glyph { - readonly handle: GlyphHandle; - readonly #font: Font; readonly #source: Source; + readonly #fallbackHandle: GlyphHandle; readonly #layerState: GlyphLayerState; - readonly #variationData: GlyphVariationData | null; - readonly #glyphId: GlyphId | null; + readonly #glyphId: GlyphId; readonly #geometry: ComputedSignal; @@ -1333,34 +1335,37 @@ export class Glyph { constructor( font: Font, + glyphId: GlyphId, handle: GlyphHandle, source: Source, - state: GlyphState, - glyphId?: GlyphId, + state: GlyphLayerState, ) { - this.handle = handle; + this.#fallbackHandle = handle; this.#font = font; this.#source = source; - this.#glyphId = glyphId ?? null; - - this.#layerState = new GlyphLayerState(state); - this.#variationData = state.variationData ?? null; + this.#glyphId = glyphId; + this.#layerState = state; this.#geometry = computed(() => this.#layerState.geometryCell.value); this.#xAdvance = computed(() => this.#layerState.coordinateBuffersCell.value.xAdvance.value); - this.#edit = new GlyphEditSession(font, state.layerId, { + this.#edit = new GlyphEditSession(font, state.state.layerId, { state: this.#layerState, geometry: this.#geometry, }); + } - if (glyphId) { - // Echoes (apply/undo/redo) fold into this session's state by layerId. - font.editCoordinator.register(state.layerId, { - state: this.#layerState, - }); - } + get id(): GlyphId { + return this.#glyphId; + } + + get handle(): GlyphHandle { + const record = this.#font.recordForId(this.#glyphId); + if (!record) return this.#fallbackHandle; + + const unicode = record.unicodes[0]; + return unicode === undefined ? { name: record.name } : { name: record.name, unicode }; } get name(): GlyphName { @@ -1451,14 +1456,17 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (exactSource) { - return this.#font.glyphLayer(this.handle, exactSource)?.geometry ?? emptyGlyphGeometry(); + return ( + this.#font.glyphLayerForId(this.#glyphId, exactSource.id)?.geometry ?? emptyGlyphGeometry() + ); } - if (!this.#variationData) { + const variationData = this.#font.variationData(this.#glyphId); + if (!variationData) { return this.#geometry.peek(); } - const values = interpolate(this.#variationData, normalize(location, [...this.#font.getAxes()])); + const values = interpolate(variationData, normalize(location, [...this.#font.getAxes()])); if (values.length === 0) { return this.#geometry.peek(); @@ -1477,7 +1485,7 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (!exactSource) return null; - return this.#font.glyphLayer(this.handle, exactSource); + return this.#font.glyphLayerForId(this.#glyphId, exactSource.id); } /** @@ -1512,26 +1520,18 @@ export class Glyph { return source.id === this.#source.id; } - /** @internal GlyphLayer caching is owned by Font.glyphLayer(). */ - createGlyphLayer(source: Source, state?: GlyphState | null): GlyphLayer | null { + /** @internal GlyphLayer caching is owned by the id-keyed FontStore model cache. */ + createGlyphLayer(source: Source, state?: GlyphLayerState | null): GlyphLayer | null { if (!this.#font.source(source.id)) return null; if (this.isPrimarySource(source)) return new GlyphLayer(source, this.#edit); if (!state) return null; - const layerState = new GlyphLayerState(state); - const geometry = computed(() => layerState.geometryCell.value); - const edit = new GlyphEditSession(this.#font, state.layerId, { - state: layerState, + const geometry = computed(() => state.geometryCell.value); + const edit = new GlyphEditSession(this.#font, state.state.layerId, { + state, geometry, }); - if (this.#glyphId) { - // Echoes for this source's layer fold here, same as the primary. - this.#font.editCoordinator.register(state.layerId, { - state: layerState, - }); - } - return new GlyphLayer(source, edit); } @@ -1556,15 +1556,6 @@ export class Glyph { } toState(): GlyphState { - const state = this.#layerState.state; - const variationData = this.#variationData; - if (!variationData) return state; - - return { - layerId: state.layerId, - structure: state.structure, - values: state.values, - variationData, - }; + return this.#layerState.state; } } diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts index f3988882..82bddd35 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts @@ -1,15 +1,14 @@ import { Bounds, Mat, type Bounds as BoundsType, type MatModel, type Point2D } from "@shift/geo"; -import type { GlyphHandle } from "@shift/bridge"; import type { Signal } from "@/lib/signals/signal"; import { computed, signal, track, type ComputedSignal } from "@/lib/signals/signal"; import type { AxisLocation } from "@/types/variation"; import { Contour, Segment } from "@shift/glyph-state"; import type { Glyph } from "./Glyph"; -import type { ContourData } from "@shift/types"; +import type { ContourData, GlyphId } from "@shift/types"; import type { LayerContourCoordinates } from "./GlyphLayerState"; interface GlyphResolver { - glyph(handle: GlyphHandle): Glyph | null; + glyphForId(glyphId: GlyphId): Glyph | null; } type OutlineCommand = @@ -355,7 +354,7 @@ export class GlyphOutline { class GlyphOutlineBuilder { readonly #location: AxisLocation; readonly #resolver: GlyphResolver; - readonly #stack = new Set(); + readonly #stack = new Set(); constructor(location: AxisLocation, resolver: GlyphResolver) { this.#location = location; @@ -367,8 +366,8 @@ class GlyphOutlineBuilder { } #collect(glyph: Glyph, matrix: MatModel): OutlineData { - if (this.#stack.has(glyph.name)) return { parts: [] }; - this.#stack.add(glyph.name); + if (this.#stack.has(glyph.id)) return { parts: [] }; + this.#stack.add(glyph.id); const parts: OutlinePart[] = []; const source = glyph.layerAt(this.#location); @@ -391,9 +390,7 @@ class GlyphOutlineBuilder { const componentValues = coordinates.components[index]; if (!componentValues) continue; - const componentGlyph = this.#resolver.glyph({ - name: component.baseGlyphName, - }); + const componentGlyph = this.#resolver.glyphForId(component.baseGlyphId); if (!componentGlyph) continue; track(componentValues.matrix); @@ -410,9 +407,7 @@ class GlyphOutlineBuilder { } for (const component of geometry.components) { - const componentGlyph = this.#resolver.glyph({ - name: component.baseGlyphName, - }); + const componentGlyph = this.#resolver.glyphForId(component.baseGlyphId); if (!componentGlyph) continue; const child = this.#collect(componentGlyph, Mat.Compose(matrix, component.matrix)); @@ -420,7 +415,7 @@ class GlyphOutlineBuilder { } } - this.#stack.delete(glyph.name); + this.#stack.delete(glyph.id); return { parts }; } } diff --git a/apps/desktop/src/renderer/src/lib/model/variation.test.ts b/apps/desktop/src/renderer/src/lib/model/variation.test.ts index ffdfb5dc..ff6c0af5 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -31,7 +31,7 @@ const SQUARE = (width: number): Array<[number, number]> => [ async function drawSquare(stack: WorkspaceStack, layerId: LayerId, width: number): Promise { const contourId = mintContourId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "addContour", addContour: { layerId, contourId, closed: false } }, { kind: "addPoints", @@ -63,7 +63,7 @@ async function variableFont(): Promise<{ const glyphId = mintGlyphId(); const regularLayerId = mintLayerId(); - const created = await stack.client.apply([ + const created = await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, @@ -83,7 +83,7 @@ async function variableFont(): Promise<{ }); const weightAxisId = mintAxisId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createAxis", createAxis: { @@ -98,7 +98,7 @@ async function variableFont(): Promise<{ }, ]); const boldSourceId = mintSourceId(); - const sourced = await stack.client.apply([ + const sourced = await stack.editCoordinator.apply([ { kind: "createSource", createSource: { @@ -113,7 +113,7 @@ async function variableFont(): Promise<{ expect(sourced.layers).toEqual([]); const boldLayerId = mintLayerId(); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "createGlyphLayer", createGlyphLayer: { layerId: boldLayerId, glyphId, sourceId: boldSourceId }, @@ -124,16 +124,33 @@ async function variableFont(): Promise<{ // variation deltas cover them. await drawSquare(stack, regularLayerId, 100); await drawSquare(stack, boldLayerId, 200); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId: regularLayerId, width: 300 } }, ]); - await stack.client.apply([ + await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId: boldLayerId, width: 500 } }, ]); return { stack, glyphId, regularLayerId, boldLayerId, bold }; } +async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { + const glyph = await stack.font.loadGlyph(glyphId, { + sourceIds: [stack.font.defaultSource.id], + }); + if (!glyph) throw new Error("Expected default glyph layer to load"); + return glyph; +} + +async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { + await stack.font.loadGlyph(glyphId, { + sourceIds: [stack.font.defaultSource.id, source.id], + }); + const layer = stack.font.glyphLayerForId(glyphId, source.id); + if (!layer) throw new Error("Expected glyph layer to load"); + return layer; +} + describe("variable editing across sources", () => { let stack: WorkspaceStack; let glyphId: GlyphId; @@ -144,15 +161,14 @@ describe("variable editing across sources", () => { }); it("opens an authored glyph layer at a non-default master", async () => { - const boldSource = await stack.font.openGlyphLayer(glyphId, bold); + const boldSource = await loadGlyphLayer(stack, glyphId, bold); - expect(boldSource).not.toBeNull(); - expect(boldSource!.contours.length).toBe(1); - expect(boldSource!.xAdvance).toBe(500); + expect(boldSource.contours.length).toBe(1); + expect(boldSource.xAdvance).toBe(500); }); it("folds echoes for edits made on a non-default master", async () => { - const boldSource = (await stack.font.openGlyphLayer(glyphId, bold))!; + const boldSource = await loadGlyphLayer(stack, glyphId, bold); const point = boldSource.allPoints[1]!; boldSource.commitPositionPatch([{ kind: "point", id: point.id, x: 250, y: 0 }]); @@ -166,7 +182,7 @@ describe("variable editing across sources", () => { }); it("interpolates geometry and metrics between masters", async () => { - const glyph = (await stack.font.openGlyph(glyphId, stack.font.defaultSource))!; + const glyph = await loadGlyph(stack, glyphId); const axis = stack.font.getAxes()[0]!; // wght 550 is halfway between the masters at 400 and 700. @@ -181,8 +197,8 @@ describe("variable editing across sources", () => { }); it("resolves live layer geometry at exact master locations", async () => { - const glyph = (await stack.font.openGlyph(glyphId, stack.font.defaultSource))!; - await stack.font.openGlyphLayer(glyphId, bold); + const glyph = await loadGlyph(stack, glyphId); + await loadGlyphLayer(stack, glyphId, bold); const axis = stack.font.getAxes()[0]!; const atBold = withAxisValue(defaultAxisLocation(stack.font.getAxes()), axis, 700); 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 baa1371b..d204fad1 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 @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { Font } from "@/lib/model/Font"; -import type { Unicode } from "@shift/types"; import { Positioner } from "./Positioner"; import { glyphTextItem as glyph } from "./types"; import { layoutTestFont, ltrRun } from "./testUtils"; @@ -42,10 +41,12 @@ describe("Positioner", () => { const run = ltrRun([a]); const positioned = positioner.position(run, font, signal(font.defaultLocation())); - const expectedBounds = font - .glyph(font.glyphHandleForUnicode(65 as Unicode)) - ?.outline(signal(font.defaultLocation())).bounds; + const record = font.recordForName("A"); + const expectedBounds = record + ? font.glyphForId(record.id)?.outline(signal(font.defaultLocation())).bounds + : null; + expect(positioned.glyphs[0].glyphId).toBe(record?.id); expect(positioned.glyphs[0].bounds).toEqual(expectedBounds); expect(positioned.glyphs[0].sourceItemIds).toEqual([a.id]); expect(positioned.glyphs[0].origin).toEqual({ x: 0, y: 0 }); @@ -60,6 +61,7 @@ describe("Positioner", () => { expect(positioned.glyphs[0].xAdvance).toBe(0); expect(positioned.glyphs[0].bounds).toBeNull(); + expect(positioned.glyphs[0].glyphId).toBeNull(); }); // Empty run → empty positioned glyphs, zero advance. 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 deb2c2cd..c4be713a 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/Positioner.ts @@ -1,10 +1,10 @@ import { displayAdvance, isNonSpacingGlyph } from "@/lib/utils/unicode"; import type { GlyphTextItem, PositionedRun, SegmentedRun } from "./types"; -import { Font } from "@/lib/model/Font"; +import type { Font } from "@/lib/model/Font"; 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"; +import type { GlyphRecord, Source } from "@shift/types"; /** * No-shape positioner — literal LTR advance walk, `cluster = clusterStart + i`. @@ -21,8 +21,8 @@ export class Positioner { const source = font.sourceAtOrDefault(designLocation.peek()); for (const [idx, g] of run.glyphs.entries()) { - const handle = { name: g.glyphName }; - const glyph = font.glyph(handle); + const record = font.recordForName(g.glyphName); + const glyph = record ? font.glyphForId(record.id) : null; let glyphName = g.glyphName; let bounds: Bounds | null = null; @@ -37,6 +37,7 @@ export class Positioner { totalAdvance += xAdvance; glyphs.push({ + glyphId: record?.id ?? null, glyphName, sourceItemIds: [g.id], origin, @@ -55,7 +56,8 @@ export class Positioner { /** Resolve a glyph item to its display advance (handles invisibles, fallbacks). */ export function resolveAdvance(item: GlyphTextItem, font: Font, source: Source | null): number { - const raw = source ? (font.glyphLayer({ name: item.glyphName }, source)?.xAdvance ?? 0) : 0; + const record = recordForTextItem(item, font); + const raw = source && record ? (font.glyphLayerForId(record.id, source.id)?.xAdvance ?? 0) : 0; return displayAdvance(raw, item.glyphName, item.codepoint); } @@ -67,7 +69,8 @@ export function resolveGlyphOffset( if (!isNonSpacingGlyph(item.glyphName, item.codepoint)) return { x: 0, y: 0 }; if (!source) return { x: 0, y: 0 }; - const glyph = font.glyphLayer({ name: item.glyphName }, source); + const record = recordForTextItem(item, font); + const glyph = record ? font.glyphLayerForId(record.id, source.id) : null; if (!glyph) return { x: 0, y: 0 }; const metrics = font.metrics; @@ -108,3 +111,7 @@ export function resolveGlyphOffset( y: (metrics.ascender + metrics.descender) / 2 - centerY, }; } + +function recordForTextItem(item: GlyphTextItem, font: Font): GlyphRecord | null { + return font.recordForName(item.glyphName); +} 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 fe66c1f7..2c4c536e 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 @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { glyphTextItem as glyph, lineBreakTextItem } from "./types"; import { layoutTestFont, makeLayout } from "./testUtils"; import type { Font } from "@/lib/model/Font"; -import type { Unicode } from "@shift/types"; describe("TextLayout", () => { let font: Font; @@ -55,11 +54,8 @@ 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 source = font.defaultSource; - const aAdvance = - font.glyphLayer(font.glyphHandleForUnicode(65 as Unicode), source)?.xAdvance ?? 0; - const bAdvance = - font.glyphLayer(font.glyphHandleForUnicode(66 as Unicode), source)?.xAdvance ?? 0; + const aAdvance = xAdvance("A", font); + const bAdvance = xAdvance("B", font); const bLeftHalfX = aAdvance + bAdvance / 4; const hit = layout.hitTest({ x: bLeftHalfX, y: 0 }); @@ -77,12 +73,11 @@ describe("TextLayout", () => { const a = glyph("A", 65); const b = glyph("B", 66); const layout = makeLayout([a, b], font); - const source = font.defaultSource; - const aAdvance = - font.glyphLayer(font.glyphHandleForUnicode(65 as Unicode), source)?.xAdvance ?? 0; + const aAdvance = xAdvance("A", font); expect(layout.editOriginForItem(b.id)).toEqual({ x: aAdvance, y: 0 }); expect(layout.primaryGlyphForItem(b.id)?.sourceItemIds).toEqual([b.id]); + expect(layout.primaryGlyphForItem(b.id)?.glyphId).toBe(font.recordForName("B")?.id); }); it("resolves edit origin by item id after a linebreak", () => { @@ -98,9 +93,7 @@ describe("TextLayout", () => { it("returns anchors with item ids rather than cluster-only hits", () => { const b = glyph("B", 66); const layout = makeLayout([glyph("A", 65), b], font); - const source = font.defaultSource; - const aAdvance = - font.glyphLayer(font.glyphHandleForUnicode(65 as Unicode), source)?.xAdvance ?? 0; + const aAdvance = xAdvance("A", font); expect(layout.anchorAtPoint("run-1", { x: aAdvance + 1, y: 0 })).toEqual({ runId: "run-1", @@ -108,3 +101,8 @@ describe("TextLayout", () => { }); }); }); + +function xAdvance(name: string, font: Font): number { + const record = font.recordForName(name); + return record ? (font.glyphLayerForId(record.id, font.defaultSource.id)?.xAdvance ?? 0) : 0; +} 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 f7b3470f..06909414 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -6,7 +6,7 @@ * outline bounds flow through positioning. No fakes; tests assert against * values read back from the workspace, not hardcoded advances. */ -import { mintGlyphId, mintLayerId, type GlyphName, type Unicode } from "@shift/types"; +import { mintGlyphId, mintLayerId, type GlyphName } from "@shift/types"; import { signal } from "@/lib/signals/signal"; import type { Font } from "@/lib/model/Font"; import { createWorkspaceStack } from "@/testing/workspaceStack"; @@ -27,7 +27,7 @@ export async function layoutTestFont(): Promise { for (const [name, unicode, advance] of GLYPHS) { const glyphId = mintGlyphId(); const layerId = mintLayerId(); - const applied = await stack.client.apply([ + const applied = await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: name as GlyphName, unicodes: [unicode] }, @@ -44,12 +44,14 @@ export async function layoutTestFont(): Promise { const record = applied.glyphs?.find((glyph) => glyph.name === name); if (!record) throw new Error(`createGlyph did not echo ${name}`); - await stack.client.apply([{ kind: "setXAdvance", setXAdvance: { layerId, width: advance } }]); - await stack.font.openGlyph(record.id, stack.font.defaultSource); + await stack.editCoordinator.apply([ + { kind: "setXAdvance", setXAdvance: { layerId, width: advance } }, + ]); + await stack.font.loadGlyph(record.id); } - const handle = stack.font.glyphHandleForUnicode(65 as Unicode); - const a = stack.font.glyphLayer(handle, stack.font.defaultSource); + const record = stack.font.recordForName("A" as GlyphName); + const a = record ? stack.font.glyphLayerForId(record.id, stack.font.defaultSource.id) : null; if (!a) throw new Error("Expected authored glyph layer for A"); const contourId = a.addContour(); 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 264a924b..b667a079 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 { Bounds, Point2D } from "@shift/geo"; -import type { FontMetrics } from "@shift/types"; +import type { FontMetrics, GlyphId } from "@shift/types"; export type TextItemId = string; export type TextRunId = string; @@ -52,6 +52,8 @@ export function lineBreakTextItem(id: TextItemId = createTextItemId()): LineBrea } export interface PositionedGlyph { + /** Stable document glyph identity resolved during layout; null when unresolved. */ + glyphId: GlyphId | null; glyphName: string; sourceItemIds: readonly TextItemId[]; origin: Point2D; 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 f4a0c17c..0d2bfa9b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -18,13 +18,7 @@ export class TextTool extends BaseTool { } override activate(): void { - // 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.rootGlyphHandle; + const owner = this.editor.glyph.peek()?.handle ?? null; if (!owner) { this.state = { type: "typing" }; this.editor.glyphDisplay; @@ -32,6 +26,13 @@ export class TextTool extends BaseTool { } const ownerName = owner.name; + const record = this.editor.font.recordForName(ownerName); + if (record) { + this.editor.font.loadGlyph(record.id).catch((error) => { + console.error("failed to load text owner glyph", error); + }); + } + const run = this.editor.textRuns.switchTo(ownerName); run.seed(glyphTextItem(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); run.interaction.suspend(); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts index bfcb3a40..b9d69d23 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts @@ -3,20 +3,21 @@ import type { SyncCallMap, SyncEventMap, WorkspaceDocumentState, + WorkspaceGlyphSnapshot, + WorkspaceGlyphSnapshotRequest, WorkspaceSnapshot, } from "@shared/workspace/protocol"; import type { ShiftHost } from "@shared/host/ShiftHost"; -import type { AppliedChange, FontIntent, GlyphState, LayerId } from "@shift/types"; +import type { AppliedChange, FontIntent } from "@shift/types"; import { signal } from "@/lib/signals/signal"; /** * Renderer side of the workspace sync lane. * * @remarks - * `workspaceCell` is the renderer's single source of workspace truth; every - * sync-lane response is the next state. `null` currently conflates - * disconnected/empty/crashed — it becomes a tagged union when recovery lands, - * so do not derive connectedness from it. + * `workspaceCell` is the renderer's latest workspace summary. `FontStore` + * owns the font-domain projection and glyph snapshot cache; this client only + * transports workspace calls and mirrors the summary for catch-up/recovery. */ export type WorkspaceClientOptions = { /** @@ -61,11 +62,6 @@ export class WorkspaceClient { /** * Applies an intent set; the response is pure replace-grade state. - * - * @remarks - * The record fold happens here (directory follows `$workspace`); layer - * folds belong to the glyph model and land with the CS3 WorkspaceEditCoordinator — - * callers receive the AppliedChange to fold geometry themselves until then. */ async apply(intents: FontIntent[]): Promise { await this.connect(); @@ -93,6 +89,14 @@ export class WorkspaceClient { return applied === null ? null : this.#fold(applied); } + async snapshot(): Promise { + await this.connect(); + + const snapshot = await this.#require().call("workspace.snapshot", undefined); + this.workspaceCell.set(snapshot); + return snapshot; + } + /** Reads utility-owned document state through the renderer sync lane. */ async documentState(): Promise { await this.connect(); @@ -116,11 +120,13 @@ export class WorkspaceClient { return this.#setDocumentState(await this.#require().call("workspace.saveAs", { path })); } - /** Pulls replace-grade glyph state by stable layer id. */ - async layer(layerId: LayerId): Promise { + /** Pulls replace-grade glyph snapshots by stable glyph id and exact sources. */ + async glyphSnapshots( + requests: readonly WorkspaceGlyphSnapshotRequest[], + ): Promise { await this.connect(); - return this.#require().call("workspace.layer", { layerId }); + return this.#require().call("workspace.glyphSnapshots", { requests: [...requests] }); } #fold(applied: AppliedChange): AppliedChange { diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts index a94208bb..f9ec188e 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -2,7 +2,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { type FontIntent, type GlyphName, type Unicode, mintGlyphId } from "@shift/types"; +import { + mintAxisId, + mintGlyphId, + mintLayerId, + type FontIntent, + type GlyphName, + type Unicode, +} from "@shift/types"; import { createWorkspaceStack, type WorkspaceStack } from "@/testing/workspaceStack"; const createGlyph = (name: string, unicode: number): FontIntent => ({ @@ -21,23 +28,23 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => }); it("flushes queued edits before the save so the write includes them", async () => { - const { client, editCoordinator } = stack; + const { store, editCoordinator } = stack; editCoordinator.push(createGlyph("A", 65)); // queued, not yet applied const saved = await editCoordinator.save(savePath()); // flushes the push, then saves behind it - expect(client.workspaceCell.peek()?.glyphs).toHaveLength(1); // the apply was folded + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(1); // the apply was folded expect(saved).toMatchObject({ dirty: false, needsSaveAs: false }); }); it("a current-target save serializes behind a later edit", async () => { - const { client, editCoordinator } = stack; + const { store, editCoordinator } = stack; await editCoordinator.save(savePath()); // adopt a package target editCoordinator.push(createGlyph("B", 66)); const saved = await editCoordinator.save(null); // null = save to current target - expect(client.workspaceCell.peek()?.glyphs).toHaveLength(1); + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(1); expect(saved).toMatchObject({ dirty: false }); }); @@ -52,4 +59,40 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => expect(editCoordinator.commitStateCell.peek()).toBe("idle"); expect(client.documentStateCell.peek()).toMatchObject({ dirty: true }); }); + + it("marks snapshot loads after queued workspace summary edits flush", async () => { + const { font, store, editCoordinator } = stack; + const glyphId = mintGlyphId(); + const layerId = mintLayerId(); + await editCoordinator.apply([ + { + kind: "createGlyph", + createGlyph: { glyphId, name: "D" as GlyphName, unicodes: [68 as Unicode] }, + }, + { + kind: "createGlyphLayer", + createGlyphLayer: { layerId, glyphId, sourceId: font.defaultSource.id }, + }, + ]); + await font.loadGlyph(glyphId); + + const axisId = mintAxisId(); + editCoordinator.push({ + kind: "createAxis", + createAxis: { + axisId, + tag: "wght", + name: "Weight", + min: 100, + default: 400, + max: 900, + hidden: false, + }, + }); + + await font.loadGlyph(glyphId); + + expect(font.getAxes().map((axis) => axis.id)).toEqual([axisId]); + expect(store.snapshotStatus(glyphId)).toBe("loaded"); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index 29158de7..6300b2ef 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -1,15 +1,14 @@ -import type { AppliedChange, FontIntent, GlyphState, LayerId } from "@shift/types"; -import type { WorkspaceDocumentState } from "@shared/workspace/protocol"; -import { signal, type Signal } from "@/lib/signals/signal"; -import type { GlyphLayerState } from "@/lib/model/GlyphLayerState"; +import type { AppliedChange, FontIntent } from "@shift/types"; +import type { + WorkspaceDocumentState, + WorkspaceGlyphSnapshot, + WorkspaceGlyphSnapshotRequest, +} from "@shared/workspace/protocol"; +import { signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; +import type { FontStore, WorkspaceCommitState } from "@/lib/model/FontStore"; import type { WorkspaceClient } from "./WorkspaceClient"; -export type WorkspaceCommitState = "idle" | "queued" | "applying"; - -/** Where one layer's replace-grade echoes fold; registered per open session. */ -type FoldTarget = { - state: GlyphLayerState; -}; +export type { WorkspaceCommitState } from "@/lib/model/FontStore"; /** * Tracks optimistic renderer edits until the utility workspace echoes them. @@ -19,9 +18,8 @@ type FoldTarget = { * coalesce into ONE `workspace.apply` — one SQLite transaction, one undo * step. Echoes fold by substitution only (replace structure, replace * values); the queue contains zero change-application or save semantics. - * Undo, redo, and save are serialized through the same queue so none can - * overtake a pending flush. Tools never hold the queue — they speak domain - * verbs on `GlyphLayer`. + * Undo, redo, snapshot reads, and save are serialized through the same queue so + * none can overtake a pending flush. * * Save ownership lives in the utility. The renderer issues save as one more op * on this queue (see {@link save}); because it shares the FIFO edit lane, the @@ -30,19 +28,22 @@ type FoldTarget = { */ export class WorkspaceEditCoordinator { readonly #workspace: WorkspaceClient; - readonly #targets = new Map(); - readonly #settledCell = signal(true); - readonly #commitState = signal("idle", { - name: "workspace.commitState", - }); + readonly #store: FontStore; + readonly #settledCell: WritableSignal; + readonly #commitState: WritableSignal; - #queue: FontIntent[] = []; #flushQueued = false; #chain: Promise = Promise.resolve(); #busy = 0; + #pendingIntents: FontIntent[] = []; - constructor(workspace: WorkspaceClient) { + constructor(workspace: WorkspaceClient, store: FontStore) { this.#workspace = workspace; + this.#store = store; + this.#settledCell = signal(true); + this.#commitState = signal("idle", { + name: "workspace.commitState", + }); } /** @@ -65,14 +66,9 @@ export class WorkspaceEditCoordinator { return this.#commitState; } - /** Routes one layer's echoes to its session state. */ - register(layerId: LayerId, target: FoldTarget): void { - this.#targets.set(layerId, target); - } - /** Queues one intent; everything in the same microtask becomes one apply. */ push(intent: FontIntent): void { - this.#queue.push(intent); + this.#pendingIntents.push(intent); this.#settledCell.set(false); if (this.#commitState.peek() === "idle") { this.#commitState.set("queued"); @@ -86,31 +82,42 @@ export class WorkspaceEditCoordinator { /** Resolves when every queued and in-flight operation has settled. */ async settled(): Promise { - while (this.#queue.length > 0 || this.#busy > 0) { + while (this.#pendingIntents.length > 0 || this.#busy > 0) { this.#enqueueFlush(); await this.#chain; } } + apply(intents: FontIntent[]): Promise { + return this.#withFlush(async () => { + const applied = await this.#workspace.apply(intents); + this.#store.applyWorkspaceChange(applied); + return applied; + }); + } + /** Replays the latest undo entry after pending pushes flush. */ undo(): Promise { return this.#withFlush(async () => { const applied = await this.#workspace.undo(); - if (applied) this.#fold(applied); + if (applied) this.#store.applyWorkspaceChange(applied); return applied; }); } - /** Pulls replace-grade glyph state by layer id, serialized behind pending writes. */ - layer(layerId: LayerId): Promise { - return this.#withFlush(() => this.#workspace.layer(layerId)); + /** Pulls replace-grade glyph snapshots by glyph id, serialized behind pending writes. */ + async readGlyphSnapshots( + requests: readonly WorkspaceGlyphSnapshotRequest[], + ): Promise { + if (requests.length === 0) return []; + return this.#withFlush(() => this.#workspace.glyphSnapshots(requests)); } /** Replays the latest redo entry after pending pushes flush. */ redo(): Promise { return this.#withFlush(async () => { const applied = await this.#workspace.redo(); - if (applied) this.#fold(applied); + if (applied) this.#store.applyWorkspaceChange(applied); return applied; }); } @@ -138,16 +145,16 @@ export class WorkspaceEditCoordinator { #enqueueFlush(): void { this.#flushQueued = false; - if (this.#queue.length === 0) return; + if (this.#pendingIntents.length === 0) return; - const intents = this.#queue; - this.#queue = []; + const intents = this.#pendingIntents; + this.#pendingIntents = []; void this.#serialize(async () => { try { this.#commitState.set("applying"); const applied = await this.#workspace.apply(intents); - this.#fold(applied); + this.#store.applyWorkspaceChange(applied); } catch (error) { console.error("workspace apply failed; resyncing from truth", error); await this.#resync(); @@ -169,37 +176,14 @@ export class WorkspaceEditCoordinator { #afterJob(): void { this.#busy -= 1; - if (this.#busy === 0 && this.#queue.length === 0) { + if (this.#busy === 0 && this.#pendingIntents.length === 0) { this.#settledCell.set(true); this.#commitState.set("idle"); } } - /** Substitution-only fold: replace structure, replace values, never merge. */ - #fold(applied: AppliedChange): void { - for (const layer of applied.layers) { - const target = this.#targets.get(layer.layerId); - if (!target) continue; // not materialized; records grain already folded - - if (layer.structure) { - target.state.replace({ - layerId: layer.layerId, - structure: layer.structure, - values: layer.values, - }); - } else { - target.state.replaceValues(layer.values); - } - } - } - - /** Blunt recovery: re-pull truth for every registered layer and stomp. */ + /** Recovery: discard loaded projections and reload the workspace summary from utility. */ async #resync(): Promise { - for (const [layerId, target] of this.#targets) { - const state = await this.#workspace.layer(layerId); - if (state) { - target.state.replace(state); - } - } + this.#store.replaceWorkspace(await this.#workspace.snapshot()); } } diff --git a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts index ba3b5530..ff8182ff 100644 --- a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts +++ b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts @@ -14,7 +14,7 @@ await stack.createWorkspace(); const glyphId = mintGlyphId(); const layerId = mintLayerId(); -const created = await stack.client.apply([ +const created = await stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, @@ -58,29 +58,31 @@ function squareIntents(width: number) { } // Seed one contour so undo/redo and pulls always have real geometry. -await stack.client.apply([...squareIntents(100)]); +await stack.editCoordinator.apply([...squareIntents(100)]); describe("workspace apply round trip (channel + NAPI + SQLite)", () => { let width = 0; bench("values-only apply: setXAdvance", async () => { width = (width % 900) + 1; - await stack.client.apply([{ kind: "setXAdvance", setXAdvance: { layerId, width } }]); + await stack.editCoordinator.apply([{ kind: "setXAdvance", setXAdvance: { layerId, width } }]); }); // Paired with its undo so glyph size stays constant across iterations; // the number is one structural edit plus one ledger replay. bench("structural apply + undo: contour with 4 points", async () => { - await stack.client.apply([...squareIntents(200)]); - await stack.client.undo(); + await stack.editCoordinator.apply([...squareIntents(200)]); + await stack.editCoordinator.undo(); }); bench("undo + redo replay of a values entry", async () => { - await stack.client.undo(); - await stack.client.redo(); + await stack.editCoordinator.undo(); + await stack.editCoordinator.redo(); }); bench("replace-grade glyph state pull", async () => { - await stack.client.layer(layerId); + await stack.editCoordinator.readGlyphSnapshots([ + { glyphId, sourceIds: [stack.font.defaultSource.id] }, + ]); }); }); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index e0aea15a..7465d075 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -15,7 +15,7 @@ import { Editor } from "@/lib/editor/Editor"; import type { Glyph } from "@/lib/model/Glyph"; import type { ToolName } from "@/lib/tools/core"; import { registerBuiltInTools } from "@/lib/tools/tools"; -import { mintGlyphId, mintLayerId, type GlyphName, type Unicode } from "@shift/types"; +import { mintGlyphId, mintLayerId, type GlyphId, type GlyphName, type Unicode } from "@shift/types"; import type { SystemClipboard } from "@/lib/clipboard"; import { createWorkspaceStack, type WorkspaceStack } from "./workspaceStack"; @@ -59,26 +59,19 @@ export class TestEditor extends Editor { const glyph = await this.#createAndOpenGlyph(name, unicode); const record = this.font.recordForName(glyph.handle.name); if (!record) throw new Error("created glyph did not appear in the font directory"); - const location = this.font.defaultLocation(); - this.focusGlyph(record.id, location); - const itemId = this.scene.addGlyph({ - glyphId: record.id, - origin: { x: 0, y: 0 }, - }); - this.scene.setGeometryItems([itemId]); + this.#placeGlyph(record.id); return this; } - /** - * Adds another glyph to the workspace font and pulls its model into the Font cache. - */ + /** Adds another glyph to the workspace font and loads its local model. */ async addGlyph(name: string, unicode: number | null): Promise { await this.#createAndOpenGlyph(name, unicode); } async #createAndOpenGlyph(name: string, unicode: number | null): Promise { const glyphId = mintGlyphId(); - const applied = await this.#stack.client.apply([ + const sourceId = this.font.defaultSource.id; + const applied = await this.#stack.editCoordinator.apply([ { kind: "createGlyph", createGlyph: { @@ -92,7 +85,7 @@ export class TestEditor extends Editor { createGlyphLayer: { layerId: mintLayerId(), glyphId, - sourceId: this.font.defaultSource.id, + sourceId, }, }, ]); @@ -100,11 +93,17 @@ export class TestEditor extends Editor { const record = applied.glyphs?.find((glyph) => glyph.name === name); if (!record) throw new Error("createGlyph did not echo the new record"); - const glyph = await this.font.openGlyph(record.id, this.font.defaultSource); - if (!glyph) throw new Error("openGlyph returned null for a created glyph"); + const glyph = await this.font.loadGlyph(record.id, { sourceIds: [sourceId] }); + if (!glyph) throw new Error("glyphForId returned null for a loaded glyph"); return glyph; } + #placeGlyph(glyphId: GlyphId): void { + this.scene.clear(); + const itemId = this.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); + this.scene.setGeometryItems([itemId]); + } + /** Awaits every queued and in-flight apply; geometry reads confirmed truth after. */ async settle(): Promise { await this.font.editCoordinator.settled(); diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index e761cea2..b81ca23e 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -5,12 +5,14 @@ import { MessageChannel, type MessagePort as NodeMessagePort } from "node:worker import { Channel, nodePortTransport } from "@shared/workspace/channel"; import type { ShellCallMap, ShellEventMap } from "@shared/workspace/protocol"; import { WorkspaceHost } from "../../../utility/workspace/WorkspaceHost"; +import { Font } from "@/lib/model/Font"; +import { FontStore } from "@/lib/model/FontStore"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; -import { Font } from "@/lib/model/Font"; export type WorkspaceStack = { client: WorkspaceClient; + store: FontStore; editCoordinator: WorkspaceEditCoordinator; font: Font; createWorkspace(): Promise; @@ -19,7 +21,7 @@ export type WorkspaceStack = { /** * The full production editing stack, in-process: real WorkspaceHost (real * NAPI, real SQLite in a temp dir) served over real node MessagePorts, with - * the real client/editCoordinator/font wiring. No Electron, no mocks — the same + * the real client/editCoordinator/FontStore/Font wiring. No Electron, no mocks — the same * pattern as WorkspaceHost.test.ts, extended to the renderer side. */ export function createWorkspaceStack(): WorkspaceStack { @@ -40,20 +42,25 @@ export function createWorkspaceStack(): WorkspaceStack { return nodePortTransport(lane.port2); }, }); - const editCoordinator = new WorkspaceEditCoordinator(client); - const font = new Font(client.workspaceCell, editCoordinator); + const store = new FontStore(); + const editCoordinator = new WorkspaceEditCoordinator(client, store); + const font = new Font(store, editCoordinator); return { client, + store, editCoordinator, font, async createWorkspace(): Promise { await shell.call("workspace.create", undefined); await client.connect(); - if (!client.workspaceCell.peek()) { + const snapshot = client.workspaceCell.peek(); + if (!snapshot) { throw new Error("workspace stack connected without a snapshot"); } + + store.replaceWorkspace(snapshot); }, }; } diff --git a/apps/desktop/src/renderer/src/workspace/Workspace.ts b/apps/desktop/src/renderer/src/workspace/Workspace.ts index 78ce4b2f..1105b2fc 100644 --- a/apps/desktop/src/renderer/src/workspace/Workspace.ts +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -3,6 +3,7 @@ import type { WorkspaceDocumentState } from "@shared/workspace/protocol"; import type { SystemClipboard } from "@/lib/clipboard"; import { Editor } from "@/lib/editor/Editor"; import { Font } from "@/lib/model/Font"; +import { FontStore } from "@/lib/model/FontStore"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { @@ -19,6 +20,7 @@ export interface WorkspaceOptions { export class Workspace { readonly #client: WorkspaceClient; + readonly #store: FontStore; readonly #edits: WorkspaceEditCoordinator; readonly #documentBridge: WorkspaceDocumentBridge; #connection: Promise | null = null; @@ -30,13 +32,14 @@ export class Workspace { constructor(options: WorkspaceOptions) { this.#client = new WorkspaceClient(options.host); - this.#edits = new WorkspaceEditCoordinator(this.#client); + this.#store = new FontStore(); + this.#edits = new WorkspaceEditCoordinator(this.#client, this.#store); this.#documentBridge = new WorkspaceDocumentBridge({ host: options.host, edits: this.#edits, }); - this.font = new Font(this.#client.workspaceCell, this.#edits); + this.font = new Font(this.#store, this.#edits); this.editor = new Editor({ font: this.font, clipboard: options.clipboard }); this.documentStateCell = this.#client.documentStateCell; this.commitStateCell = this.#edits.commitStateCell; @@ -67,6 +70,7 @@ export class Workspace { throw new Error("workspace connected without a snapshot"); } + this.#store.replaceWorkspace(snapshot); await this.#documentBridge.connect(); } catch (error) { this.#connection = null; diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index 30a94e48..f418850c 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -4,10 +4,12 @@ import type { FontIntent, FontMetadata, FontMetrics, + GlyphId, GlyphRecord, GlyphState, - LayerId, + GlyphVariationData, Source, + SourceId, } from "@shift/types"; /** @@ -22,6 +24,23 @@ export type WorkspaceSnapshot = { axes: Axis[]; }; +export type WorkspaceGlyphLayerSnapshot = { + glyphId: GlyphId; + sourceId: SourceId; + state: GlyphState; +}; + +export type WorkspaceGlyphSnapshotRequest = { + glyphId: GlyphId; + sourceIds: SourceId[]; +}; + +export type WorkspaceGlyphSnapshot = { + glyphId: GlyphId; + variationData?: GlyphVariationData; + layers: WorkspaceGlyphLayerSnapshot[]; +}; + export type WorkspaceDocumentSourceKind = "untitled" | "package" | "imported"; /** @@ -101,13 +120,9 @@ export type SyncCallMap = { "workspace.save": { request: void; response: WorkspaceDocumentState }; /** Saves to `path` (main's Save As dialog choice) and adopts it as target. */ "workspace.saveAs": { request: { path: string }; response: WorkspaceDocumentState }; - /** - * Pulls replace-grade glyph state for one layer (resync + editor open). - * Addressed by stable LayerId — the edit identity. - */ - "workspace.layer": { - request: { layerId: LayerId }; - response: GlyphState | null; + "workspace.glyphSnapshots": { + request: { requests: WorkspaceGlyphSnapshotRequest[] }; + response: WorkspaceGlyphSnapshot[]; }; }; diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index 63725247..0efe467b 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -613,7 +613,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { await expect(redoWorkspace(sync)).resolves.toBeNull(); }); - it("workspace.layer pulls replace-grade state by stable layer id", async () => { + it("workspace.glyphSnapshots pulls bounded source snapshots by stable glyph id", async () => { const sync = await connectSyncLane(); const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); @@ -624,12 +624,24 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { { id: layerId, sourceId: snapshot.sources[0].id }, ]); - const state = await sync.call("workspace.layer", { layerId }); - expect(state?.layerId).toBe(layerId); - expect(state?.structure.contours).toEqual([]); + const glyphId = created.glyphs?.[0]?.id; + if (!glyphId) throw new Error("createGlyph did not echo glyph id"); - const missing = mintLayerId(); - await expect(sync.call("workspace.layer", { layerId: missing })).resolves.toBeNull(); + const snapshots = await sync.call("workspace.glyphSnapshots", { + requests: [{ glyphId, sourceIds: [snapshot.sources[0].id] }], + }); + expect(snapshots).toHaveLength(1); + expect(snapshots[0].glyphId).toBe(glyphId); + expect(snapshots[0].layers).toHaveLength(1); + expect(snapshots[0].layers[0].state.layerId).toBe(layerId); + expect(snapshots[0].layers[0].state.structure.contours).toEqual([]); + + const missing = mintGlyphId(); + await expect( + sync.call("workspace.glyphSnapshots", { + requests: [{ glyphId: missing, sourceIds: [snapshot.sources[0].id] }], + }), + ).resolves.toEqual([]); }); it("CS0 skeleton: measures the apply round trip through the full stack", async () => { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 236d53a7..7cfc266f 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -1,4 +1,5 @@ import { createBridge, type ShiftBridge } from "@shift/bridge"; +import path from "node:path"; import { serveChannel, type ChannelServer, type Transport } from "../../shared/workspace/channel"; import type { ShellCallMap, @@ -7,6 +8,7 @@ import type { SyncEventMap, WorkspaceDocumentSourceKind, WorkspaceDocumentState, + WorkspaceGlyphSnapshot, WorkspaceSnapshot, } from "../../shared/workspace/protocol"; import { DocumentStorage } from "./DocumentStorage"; @@ -100,7 +102,8 @@ export class WorkspaceHost { // every committed apply/undo/redo, so it never writes stale state. "workspace.save": () => this.#serialize(() => this.#save()), "workspace.saveAs": ({ path }) => this.#serialize(() => this.#saveAs(path)), - "workspace.layer": ({ layerId }) => this.#serialize(() => this.#bridge.getLayer(layerId)), + "workspace.glyphSnapshots": ({ requests }) => + this.#serialize(() => this.#bridge.getGlyphSnapshots(requests) as WorkspaceGlyphSnapshot[]), }); } @@ -126,7 +129,9 @@ export class WorkspaceHost { } #open(path: string): WorkspaceDocumentState { - const recovery = this.#bridge.findRecoverableWorkspace(path, this.#documents.listDrafts()); + const recovery = isShiftPackagePath(path) + ? this.#bridge.findRecoverableWorkspace(path, this.#documents.listDrafts()) + : null; const document = recovery ?? this.#documents.createDraft(); if (recovery) { @@ -192,3 +197,7 @@ function parseDocumentSourceKind(sourceKind: string): WorkspaceDocumentSourceKin throw new Error(`unknown document source kind: ${sourceKind}`); } + +function isShiftPackagePath(sourcePath: string): boolean { + return path.extname(sourcePath).toLowerCase() === ".shift"; +} diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index 658a542f..ba029f48 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -78,8 +78,10 @@ describe("Bridge", () => { function glyphState(name) { const glyph = bridge.getGlyphs().find((record) => record.name === name); - const layer = glyph.layers.find((candidate) => candidate.sourceId === defaultSourceId()); - return bridge.getLayer(layer.id); + const snapshots = bridge.getGlyphSnapshots([ + { glyphId: glyph.id, sourceIds: [defaultSourceId()] }, + ]); + return snapshots[0]?.layers[0]?.state; } it("creates an untitled workspace with default committed font metadata", () => { @@ -160,7 +162,7 @@ describe("Bridge", () => { expect(Array.from(layer.values)).toEqual([500, 10, 20]); }); - it("moves points and reads them back through the id-addressed glyph state", () => { + it("moves points and reads them back through glyph-addressed snapshots", () => { const layerId = createGlyphLayer(); const contourId = addContour(layerId); const { pointId } = addPoint(layerId, contourId, 10, 20); diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index af9e4865..56eae32b 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -42,8 +42,8 @@ export declare class Bridge { * redo stack is empty. */ redo(): NapiAppliedChange | null - /** Layer-addressed glyph state. LayerId is the stable edit identity. */ - getLayer(layerId: LayerId): NapiGlyphState | null + /** Glyph-addressed snapshots for renderer-local synchronous font state. */ + getGlyphSnapshots(requests: Array): Array isVariable(): boolean getAxes(): Array getSources(): Array @@ -304,6 +304,12 @@ export interface NapiGlyphLayerRecord { sourceId: SourceId } +export interface NapiGlyphLayerSnapshot { + glyphId: GlyphId + sourceId: SourceId + state: NapiGlyphState +} + export interface NapiGlyphMaster { sourceId: SourceId sourceName: string @@ -321,12 +327,22 @@ export interface NapiGlyphRecord { layers: Array } +export interface NapiGlyphSnapshot { + glyphId: GlyphId + variationData?: NapiGlyphVariationData + layers: Array +} + +export interface NapiGlyphSnapshotRequest { + glyphId: GlyphId + sourceIds: SourceId[] +} + export interface NapiGlyphState { layerId: LayerId structure: NapiGlyphStructure /** Numeric glyph state ordered to match `GlyphStructure`. */ values: Float64Array - variationData?: NapiGlyphVariationData } export interface NapiGlyphStructure { diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index a6e949e5..046ef11b 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -12,11 +12,12 @@ use shift_font::{ use shift_wire::{ bridges::napi::{ NapiAnchorSeed, NapiAppliedChange, NapiAxis, NapiFontIntent, NapiFontMetadata, NapiFontMetrics, - NapiGlyphRecord, NapiGlyphState, NapiLayerReplaced, NapiLocation, NapiPointSeed, NapiSource, + NapiGlyphRecord, NapiGlyphSnapshot, NapiGlyphSnapshotRequest, NapiLayerReplaced, NapiLocation, + NapiPointSeed, NapiSource, }, interpolation::{build_glyph_variation_data, build_masters, GlyphVariationBuild}, - Axis, FontMetadata, FontMetrics, GlyphChangedEntities, GlyphRecord, GlyphState, GlyphStructure, - Source, + Axis, FontMetadata, FontMetrics, GlyphChangedEntities, GlyphLayerSnapshot, GlyphRecord, + GlyphSnapshot, GlyphSnapshotRequest, GlyphState, GlyphStructure, Source, }; use shift_workspace::{ FontWorkspace, NewWorkspace, RecoverySelection, WorkspaceError, WorkspaceRecoveryCandidate, @@ -472,30 +473,45 @@ impl Bridge { Ok(Some(self.applied_echo(outcome)?)) } - /// Layer-addressed glyph state. LayerId is the stable edit identity. + /// Glyph-addressed snapshots for renderer-local synchronous font state. #[napi] - pub fn get_layer( + pub fn get_glyph_snapshots( &self, - #[napi(ts_arg_type = "LayerId")] layer_id: String, - ) -> errors::Result> { - let layer_id = parse::(&layer_id)?; - + requests: Vec, + ) -> errors::Result> { let font = self.font()?; - let Some(glyph_id) = font.glyph_id_by_layer(layer_id.clone()) else { - return Ok(None); - }; - let Some(glyph) = font.glyph(glyph_id) else { - return Ok(None); - }; - let Some(layer) = font.layer(layer_id) else { - return Ok(None); - }; + let mut snapshots = Vec::new(); + + for request in requests { + let request = GlyphSnapshotRequest::from(request); + let glyph_id = request.glyph_id; + let source_ids = request.source_ids; + let Some(glyph) = font.glyph(glyph_id.clone()) else { + continue; + }; - let variation_data = self - .variation_build_for_glyph(glyph)? - .and_then(|(_, build)| build.variation_data); + let variation_data = self + .variation_build_for_glyph(glyph)? + .and_then(|(_, build)| build.variation_data); - Ok(Some(GlyphState::from_layer(layer, variation_data).into())) + let layers = source_ids + .into_iter() + .filter_map(|source_id| glyph.layer_for_source(source_id)) + .map(|layer| GlyphLayerSnapshot { + glyph_id: glyph_id.clone(), + source_id: layer.source_id(), + state: GlyphState::from_layer(layer), + }) + .collect(); + + snapshots.push(GlyphSnapshot { + glyph_id, + variation_data, + layers, + }); + } + + Ok(snapshots.into_iter().map(Into::into).collect()) } #[napi] @@ -868,10 +884,11 @@ mod tests { use shift_wire::bridges::napi::{ NapiAddAnchorsIntent, NapiAddContourIntent, NapiAddPointsIntent, NapiCreateAxisIntent, NapiCreateGlyphIntent, NapiCreateGlyphLayerIntent, NapiCreateSourceIntent, - NapiDeleteAxisIntent, NapiDeleteSourceIntent, NapiLocation, NapiMoveAnchorsIntent, - NapiMovePointsIntent, NapiPointSeed, NapiPointType, NapiRemoveAnchorsIntent, - NapiRemovePointsIntent, NapiReverseContourIntent, NapiSetContourClosedIntent, - NapiSetPointSmoothIntent, NapiSetXAdvanceIntent, NapiTranslatePointsIntent, + NapiDeleteAxisIntent, NapiDeleteSourceIntent, NapiGlyphSnapshotRequest, NapiGlyphState, + NapiLocation, NapiMoveAnchorsIntent, NapiMovePointsIntent, NapiPointSeed, NapiPointType, + NapiRemoveAnchorsIntent, NapiRemovePointsIntent, NapiReverseContourIntent, + NapiSetContourClosedIntent, NapiSetPointSmoothIntent, NapiSetXAdvanceIntent, + NapiTranslatePointsIntent, }; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1475,18 +1492,27 @@ mod tests { .find(|record| record.name == name) .expect("glyph record should exist"); let source_id = default_source_id(bridge); - let layer_id = record - .layers - .iter() - .find(|layer| layer.source_id == source_id) - .expect("glyph default layer should exist") - .id - .clone(); - bridge - .get_layer(layer_id) - .unwrap() - .expect("glyph state should be readable") + glyph_source_state(bridge, &record.id, &source_id).expect("glyph state should be readable") + } + + fn glyph_source_state( + bridge: &Bridge, + glyph_id: &str, + source_id: &str, + ) -> Option { + let snapshots = bridge + .get_glyph_snapshots(vec![NapiGlyphSnapshotRequest { + glyph_id: glyph_id.to_string(), + source_ids: vec![source_id.to_string()], + }]) + .unwrap(); + + snapshots + .into_iter() + .next() + .and_then(|snapshot| snapshot.layers.into_iter().next()) + .map(|layer| layer.state) } fn contour_point_count(bridge: &Bridge) -> usize { @@ -1961,10 +1987,8 @@ mod tests { ) .unwrap(); - let state = bridge - .get_layer(layer_id.clone()) - .unwrap() - .expect("the explicit layer must resolve by LayerId"); + let state = glyph_source_state(&bridge, &glyph_id, "source_bold") + .expect("the explicit layer must resolve by glyph and source"); assert_eq!(state.layer_id, layer_id); assert_eq!(applied.layers[0].layer_id, layer_id); let glyphs = applied @@ -2065,7 +2089,7 @@ mod tests { None, ) .unwrap(); - assert!(bridge.get_layer(bold_layer_id.clone()).unwrap().is_some()); + assert!(glyph_source_state(&bridge, &glyph_id, "source_bold").is_some()); let applied = bridge .apply(vec![delete_source_intent("source_bold")], None) @@ -2079,10 +2103,8 @@ mod tests { .expect("deleteSource layer removal must echo glyph records"); assert_eq!(glyphs[0].layers.len(), 1); assert!(applied.layers.is_empty()); - assert!(bridge.get_layer(bold_layer_id).unwrap().is_none()); - let default_state = bridge - .get_layer(default_layer_id.clone()) - .unwrap() + assert!(glyph_source_state(&bridge, &glyph_id, "source_bold").is_none()); + let default_state = glyph_source_state(&bridge, &glyph_id, &default_source_id(&bridge)) .expect("default source must keep its layer"); assert_eq!(default_state.layer_id, default_layer_id); } @@ -2234,7 +2256,7 @@ mod tests { } #[test] - fn get_layer_reads_applied_edits() { + fn get_glyph_snapshots_read_applied_edits() { let mut bridge = bridge_with_workspace(); let (layer_id, contour_id) = pen_setup(&mut bridge); let point_id = shift_font::PointId::new().to_string(); @@ -2259,11 +2281,19 @@ mod tests { } #[test] - fn get_layer_returns_none_for_missing_layer() { + fn get_glyph_snapshots_returns_none_for_missing_glyph() { let bridge = bridge_with_workspace(); - let missing_layer_id = shift_font::LayerId::new().to_string(); + let missing_glyph_id = shift_font::GlyphId::new().to_string(); + let source_id = default_source_id(&bridge); + + let snapshots = bridge + .get_glyph_snapshots(vec![NapiGlyphSnapshotRequest { + glyph_id: missing_glyph_id, + source_ids: vec![source_id], + }]) + .unwrap(); - assert!(bridge.get_layer(missing_layer_id).unwrap().is_none()); + assert!(snapshots.is_empty()); } #[test] diff --git a/crates/shift-source/src/package.rs b/crates/shift-source/src/package.rs index 58056757..7fc4737f 100644 --- a/crates/shift-source/src/package.rs +++ b/crates/shift-source/src/package.rs @@ -104,27 +104,27 @@ pub enum SourcePackageError { #[error("invalid source package module {path}: {message}")] InvalidModule { path: String, message: String }, - #[error("source package JSON error in {path}")] + #[error("source package JSON error in {path}: {source}")] Json { path: String, #[source] source: serde_json::Error, }, - #[error("source package text error in {path}")] + #[error("source package text error in {path}: {source}")] Text { path: String, #[source] source: FromUtf8Error, }, - #[error("source package zip error")] + #[error("source package zip error: {0}")] Zip(#[from] ZipError), - #[error("source package file-system error")] + #[error("source package file-system error: {0}")] Io(#[from] io::Error), - #[error("font model error")] + #[error(transparent)] Font(#[from] shift_font::CoreError), } diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 91fa2178..535aa97c 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -1,12 +1,12 @@ #[derive(Debug, thiserror::Error)] pub enum StoreError { - #[error("sqlite error")] + #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), - #[error("json error")] + #[error("json error: {0}")] Json(#[from] serde_json::Error), - #[error("font error")] + #[error(transparent)] Font(#[from] shift_font::error::CoreError), #[error("unknown source kind: {0}")] diff --git a/crates/shift-wire/src/bridges/napi/mod.rs b/crates/shift-wire/src/bridges/napi/mod.rs index 7560af72..c9177cae 100644 --- a/crates/shift-wire/src/bridges/napi/mod.rs +++ b/crates/shift-wire/src/bridges/napi/mod.rs @@ -2,12 +2,13 @@ use std::collections::HashMap; use napi::bindgen_prelude::Float64Array; use napi_derive::napi; -use shift_font::{GlyphId, PointType as IrPointType}; +use shift_font::{GlyphId, PointType as IrPointType, SourceId}; use crate::{ AnchorData, Axis, AxisTent, ComponentData, ContourData, FontMetadata, FontMetrics, - GlyphChangedEntities, GlyphLayerRecord, GlyphMaster, GlyphRecord, GlyphState, GlyphStructure, - GlyphVariationData, Location, PointData, PointType, Source, + GlyphChangedEntities, GlyphLayerRecord, GlyphLayerSnapshot, GlyphMaster, GlyphRecord, + GlyphSnapshot, GlyphSnapshotRequest, GlyphState, GlyphStructure, GlyphVariationData, Location, + PointData, PointType, Source, }; #[napi(string_enum = "camelCase")] @@ -209,7 +210,6 @@ pub struct NapiGlyphState { pub structure: NapiGlyphStructure, /// Numeric glyph state ordered to match `GlyphStructure`. pub values: Float64Array, - pub variation_data: Option, } impl From for NapiGlyphState { @@ -218,7 +218,64 @@ impl From for NapiGlyphState { layer_id: state.layer_id.to_string(), structure: state.structure.into(), values: state.values.into(), - variation_data: state.variation_data.map(Into::into), + } + } +} + +#[napi(object)] +pub struct NapiGlyphLayerSnapshot { + #[napi(ts_type = "GlyphId")] + pub glyph_id: String, + #[napi(ts_type = "SourceId")] + pub source_id: String, + pub state: NapiGlyphState, +} + +impl From for NapiGlyphLayerSnapshot { + fn from(snapshot: GlyphLayerSnapshot) -> Self { + Self { + glyph_id: snapshot.glyph_id.to_string(), + source_id: snapshot.source_id.to_string(), + state: snapshot.state.into(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphSnapshotRequest { + #[napi(ts_type = "GlyphId")] + pub glyph_id: String, + #[napi(ts_type = "SourceId[]")] + pub source_ids: Vec, +} + +impl From for GlyphSnapshotRequest { + fn from(request: NapiGlyphSnapshotRequest) -> Self { + Self { + glyph_id: GlyphId::from_raw(request.glyph_id), + source_ids: request + .source_ids + .into_iter() + .map(SourceId::from_raw) + .collect(), + } + } +} + +#[napi(object)] +pub struct NapiGlyphSnapshot { + #[napi(ts_type = "GlyphId")] + pub glyph_id: String, + pub variation_data: Option, + pub layers: Vec, +} + +impl From for NapiGlyphSnapshot { + fn from(snapshot: GlyphSnapshot) -> Self { + Self { + glyph_id: snapshot.glyph_id.to_string(), + variation_data: snapshot.variation_data.map(Into::into), + layers: snapshot.layers.into_iter().map(Into::into).collect(), } } } diff --git a/crates/shift-wire/src/lib.rs b/crates/shift-wire/src/lib.rs index 7bd478c4..e66f8436 100644 --- a/crates/shift-wire/src/lib.rs +++ b/crates/shift-wire/src/lib.rs @@ -215,20 +215,41 @@ 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 { + pub fn from_layer(layer: &GlyphLayer) -> Self { Self { layer_id: layer.id(), structure: GlyphStructure::from(layer), values: values_from_layer(layer), - variation_data, } } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphLayerSnapshot { + pub glyph_id: GlyphId, + pub source_id: SourceId, + pub state: GlyphState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphSnapshotRequest { + pub glyph_id: GlyphId, + pub source_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphSnapshot { + pub glyph_id: GlyphId, + pub variation_data: Option, + pub layers: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GlyphStructure { diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 2282b3a0..037c9680 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -23,16 +23,16 @@ pub enum WorkspaceError { #[error(transparent)] Font(#[from] CoreError), - #[error("source package error")] + #[error(transparent)] Source(#[from] shift_source::SourcePackageError), - #[error("store error")] + #[error(transparent)] Store(#[from] shift_store::StoreError), - #[error("font backend error")] + #[error(transparent)] Backend(#[from] shift_backends::BackendError), - #[error("font export error")] + #[error(transparent)] Export(#[from] shift_backends::ExportError), #[error("workspace needs a save path")] @@ -56,7 +56,7 @@ pub enum WorkspaceError { #[error("invalid UTF-8 in workspace path: {0}")] InvalidPathUtf8(PathBuf), - #[error("workspace file-system error")] + #[error("workspace file-system error: {0}")] Io(#[from] io::Error), } diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index e266cf8d..d790cd04 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -47,8 +47,8 @@ export interface BridgeApi { * redo stack is empty. */ redo(): AppliedChange | null - /** Layer-addressed glyph state. LayerId is the stable edit identity. */ - getLayer(layerId: LayerId): GlyphState | null + /** Glyph-addressed snapshots for renderer-local synchronous font state. */ + getGlyphSnapshots(requests: Array): Array isVariable(): boolean getAxes(): Array getSources(): Array @@ -309,6 +309,12 @@ export interface GlyphLayerRecord { sourceId: SourceId } +export interface GlyphLayerSnapshot { + glyphId: GlyphId + sourceId: SourceId + state: GlyphState +} + export interface GlyphMaster { sourceId: SourceId sourceName: string @@ -326,12 +332,22 @@ export interface GlyphRecord { layers: Array } +export interface GlyphSnapshot { + glyphId: GlyphId + variationData?: GlyphVariationData + layers: Array +} + +export interface GlyphSnapshotRequest { + glyphId: GlyphId + sourceIds: SourceId[] +} + export interface GlyphState { layerId: LayerId structure: GlyphStructure /** Numeric glyph state ordered to match `GlyphStructure`. */ values: Float64Array - variationData?: GlyphVariationData } export interface GlyphStructure {