From 8ecd2e05c0fa8b37e7acc20ae570f47530ac1b9d Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 21:50:11 +0100 Subject: [PATCH 01/14] Introduce renderer FontStore glyph snapshots --- .../renderer/src/components/editor/Canvas.tsx | 21 +- .../src/lib/editor/rendering/RenderFrame.ts | 23 +- .../src/renderer/src/lib/model/Font.test.ts | 109 +++++-- .../src/renderer/src/lib/model/Font.ts | 167 ++++------ .../src/renderer/src/lib/model/FontStore.ts | 307 ++++++++++++++++++ .../src/renderer/src/lib/model/Glyph.ts | 78 ++--- .../renderer/src/lib/model/GlyphOutline.ts | 21 +- .../src/lib/model/GlyphSnapshotRequests.ts | 95 ++++++ .../renderer/src/lib/model/variation.test.ts | 43 ++- .../renderer/src/lib/text/layout/testUtils.ts | 8 +- .../src/lib/workspace/WorkspaceClient.ts | 32 +- .../WorkspaceEditCoordinator.test.ts | 8 +- .../lib/workspace/WorkspaceEditCoordinator.ts | 114 +++---- .../renderer/src/perf/workspaceApply.bench.ts | 18 +- .../src/renderer/src/testing/TestEditor.ts | 10 +- .../renderer/src/testing/workspaceStack.ts | 21 +- .../src/renderer/src/workspace/Workspace.ts | 11 +- apps/desktop/src/shared/workspace/protocol.ts | 31 +- .../utility/workspace/WorkspaceHost.test.ts | 24 +- .../src/utility/workspace/WorkspaceHost.ts | 4 +- crates/shift-bridge/__test__/index.spec.mjs | 8 +- crates/shift-bridge/index.d.ts | 22 +- crates/shift-bridge/src/bridge.rs | 130 +++++--- crates/shift-wire/src/bridges/napi/mod.rs | 67 +++- crates/shift-wire/src/lib.rs | 27 +- packages/types/src/bridge/generated.ts | 22 +- 26 files changed, 1008 insertions(+), 413 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/model/FontStore.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index 410db789..ac918fd8 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -4,7 +4,7 @@ import { useParams } from "react-router-dom"; import { CanvasContextProvider } from "@/context/CanvasContext"; import { useDebugSafe } from "@/context/DebugContext"; import { useSignalState } from "@/lib/signals"; -import { useEditor } from "@/workspace/WorkspaceContext"; +import { useEditor, useWorkspace } from "@/workspace/WorkspaceContext"; import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; import { StaticScene } from "./StaticScene"; @@ -14,12 +14,14 @@ import { Vec2 } from "@shift/geo"; import { asGlyphId } from "@shift/types"; export const Canvas: FC = () => { + const workspace = useWorkspace(); const editor = useEditor(); const debug = useDebugSafe(); const { glyphId: glyphIdParam } = useParams(); const containerRef = useRef(null); const cursorStyle = useSignalState(editor.cursorCell); + const fontLoaded = useSignalState(editor.font.$loaded); useEffect(() => { if (!glyphIdParam) { @@ -36,10 +38,25 @@ export const Canvas: FC = () => { const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); editor.scene.setGeometryItems([itemId]); + let cancelled = false; + + async function loadGlyphSnapshot(): Promise { + try { + await workspace.glyphSnapshots.load([glyphId], [editor.font.defaultSource.id]); + if (cancelled) return; + editor.focusGlyph(glyphId, editor.font.defaultLocation()); + } catch (error) { + console.error("failed to load glyph snapshot", error); + } + } + + void loadGlyphSnapshot(); + return () => { + cancelled = true; editor.scene.clear(); }; - }, [glyphIdParam]); + }, [editor, fontLoaded, glyphIdParam, workspace]); useEffect(() => { const element = containerRef.current; 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..3a34d1d2 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts @@ -20,7 +20,6 @@ import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; interface BackgroundGlyphFrame { readonly item: SceneGlyph; - readonly model: Glyph; readonly advance: number; readonly geometryShown: boolean; } @@ -100,20 +99,24 @@ 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; + const xAdvance = instance?.xAdvanceCell.value ?? this.#editor.font.defaultXAdvance; + const unicode = glyph + ? Number.isFinite(glyph.unicode) + ? glyph.unicode + : null + : (record.unicodes[0] ?? null); glyphs.push({ item, - model: glyph, - advance: displayAdvance(xAdvance, glyph.name, unicode), - geometryShown: scene.geometryItems.includes(item.id), + advance: displayAdvance(xAdvance, record.name, unicode), + geometryShown, }); } 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..56c0301c 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: { @@ -156,7 +156,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] }, @@ -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 }, @@ -192,7 +192,8 @@ describe("font-level intents make the font variable", () => { expect(committed?.layers).toEqual(record.layers); expect(stack.font.glyphLayerRecord(record.id, source.id)).toEqual(record.layers[0]); - const glyph = await stack.font.openGlyph(record.id, source); + await stack.glyphSnapshots.load([record.id], [source.id]); + const glyph = stack.font.glyphById(record.id); expect(glyph?.xAdvance).toBe(stack.font.defaultXAdvance); }); @@ -201,7 +202,7 @@ describe("font-level intents make the font variable", () => { 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 +219,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 +234,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 +245,8 @@ describe("font-level intents make the font variable", () => { }, ]); - const glyph = await stack.font.openGlyph(glyphId, stack.font.defaultSource); + await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id]); + const glyph = stack.font.glyphById(glyphId); if (!glyph) throw new Error("Expected default glyph layer to open"); expect(glyph.xAdvance).toBe(640); @@ -257,4 +259,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.glyphSnapshots.load([glyphId], [defaultSourceId]); + const boldLoad = stack.glyphSnapshots.load([glyphId], [boldSourceId]); + await Promise.all([regularLoad, boldLoad]); + + expect(stack.font.glyphLayerById(glyphId, regularSource)?.id).toBe(defaultLayerId); + expect(stack.font.glyphLayerById(glyphId, boldSource)?.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..3b8a89f3 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,10 @@ 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 { Glyph, type GlyphLayer } from "./Glyph"; import { GlyphOutline } from "./GlyphOutline"; +import type { FontStore } from "./FontStore"; +import type { GlyphLayerState } from "./GlyphLayerState"; import type { GlyphHandle } from "@shift/bridge"; import { axisLocationDistanceSquared, @@ -296,33 +297,31 @@ export class Font { readonly #$glyphRecords: Signal; readonly #directory: Signal; + readonly #store: FontStore; readonly #glyphs = new Map(); /** Open glyph models keyed by stable id; survives directory re-keys. */ readonly #glyphsById = new Map(); readonly #glyphLayers = new Map(); readonly #editCoordinator: WorkspaceEditCoordinator | null; - #cachesKeyedTo: GlyphDirectory | null = null; + #documentId: string | null = null; /** * 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 store - Renderer-local owner of committed records and loaded glyph snapshots. * @param editCoordinator - Optional queue 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); } @@ -631,32 +630,33 @@ export class Font { * @returns The glyph model, or `null` when the glyph has no state for the default source. */ glyph(handle: GlyphHandle): Glyph | null { + const record = this.#directory.peek().recordForName(handle.name); + return record ? this.glyphById(record.id) : null; + } + + glyphById(glyphId: GlyphId): Glyph | null { if (!this.loaded) return null; - this.#syncCaches(); + this.#clearDocumentModelsIfNeeded(); - const cached = this.#glyphs.get(handle.name); - if (cached) return cached; - - // 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 record = directory.recordForId(glyphId); + if (!record) return null; + + const cached = this.#glyphsById.get(glyphId); + if (cached) return cached; const source = this.defaultSource; - const layer = record ? directory.layerForGlyphAtSource(record.id, source.id) : null; + const layer = directory.layerForGlyphAtSource(glyphId, source.id); if (!layer) return null; const state = this.layerState(layer.id); if (!state) return null; - const glyph = new Glyph(this, handle, source, state); - this.#glyphs.set(handle.name, glyph); + const handle = directory.glyphHandleForName(record.name); + const glyph = new Glyph(this, glyphId, handle, source, state); + this.#glyphsById.set(glyphId, glyph); + this.#glyphs.set(record.name, glyph); return glyph; } @@ -676,17 +676,22 @@ export class Font { * @returns The authored glyph layer, or `null` when the source or layer state is unavailable. */ glyphLayer(handle: GlyphHandle, source: Source): GlyphLayer | null { + const record = this.#directory.peek().recordForName(handle.name); + return record ? this.glyphLayerById(record.id, source) : null; + } + + glyphLayerById(glyphId: GlyphId, source: Source): GlyphLayer | null { if (!this.source(source.id)) return null; - this.#syncCaches(); - const layer = this.#directory.peek().layerForGlyphNameAtSource(handle.name, source.id); + this.#clearDocumentModelsIfNeeded(); + const layer = this.#directory.peek().layerForGlyphAtSource(glyphId, source.id); if (!layer) return null; - const key = glyphLayerModelKey(handle.name, source.id); + const key = glyphLayerModelKey(glyphId, source.id); const cached = this.#glyphLayers.get(key); if (cached) return cached; - const glyph = this.glyph(handle); + const glyph = this.glyphById(glyphId); if (!glyph) return null; const state = glyph.isPrimarySource(source) ? undefined : this.layerState(layer.id); @@ -715,10 +720,12 @@ export class Font { * @param layerId - Stable layer identity to read. * @returns Raw glyph state, or `null` when the bridge cannot provide state. */ - 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 +839,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,14 +892,15 @@ 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; + #clearDocumentModelsIfNeeded(): void { + const workspace = this.#store.workspaceCell.peek(); + const documentId = workspace?.documentId ?? null; + if (this.#documentId !== documentId) { + this.#glyphs.clear(); + this.#glyphsById.clear(); + this.#glyphLayers.clear(); + this.#documentId = documentId; + } } defaultLocation(): AxisLocation { @@ -961,8 +908,8 @@ export class Font { } } -function glyphLayerModelKey(name: GlyphName, sourceId: SourceId): GlyphLayerKey { - return `${sourceId}:${name}` as GlyphLayerKey; +function glyphLayerModelKey(glyphId: GlyphId, sourceId: SourceId): GlyphLayerKey { + return `${glyphId}:${sourceId}` as GlyphLayerKey; } function glyphLayerKey(glyphId: GlyphId, sourceId: SourceId): string { 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..52eca681 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -0,0 +1,307 @@ +import type { + AppliedChange, + FontIntent, + GlyphId, + GlyphStructure, + GlyphState, + GlyphVariationData, + LayerId, + SourceId, +} from "@shift/types"; +import type { + WorkspaceGlyphLayerSnapshot, + WorkspaceGlyphSnapshot, + WorkspaceSnapshot, +} from "@shared/workspace/protocol"; +import { batch, signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; +import { GlyphLayerState } from "./GlyphLayerState"; + +export type GlyphSnapshotStatus = "missing" | "loading" | "loaded" | "stale"; +export type WorkspaceCommitState = "idle" | "queued" | "applying"; + +type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; + +const EMPTY_STATUS: ReadonlyMap = new Map(); + +/** + * Renderer-local owner for font records, loaded glyph snapshots, and pending + * local commits. + */ +export class FontStore { + readonly #workspace: WritableSignal; + readonly #snapshotStatus: WritableSignal>; + readonly #settledCell = signal(true); + readonly #commitState = signal("idle", { + name: "workspace.commitState", + }); + + readonly #layerStates = new Map(); + readonly #layerByGlyphSource = new Map(); + readonly #glyphByLayer = new Map(); + readonly #variationDataByGlyph = new Map(); + readonly #snapshotGeneration = new Map(); + + #generation = 0; + #pendingIntents: FontIntent[] = []; + + 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; + } + + get settledCell(): Signal { + return this.#settledCell; + } + + get commitStateCell(): Signal { + return this.#commitState; + } + + get generation(): number { + return this.#generation; + } + + replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { + batch(() => { + this.#generation += 1; + this.#workspace.set(snapshot); + this.#indexWorkspace(snapshot); + this.#layerStates.clear(); + this.#variationDataByGlyph.clear(); + this.#snapshotGeneration.clear(); + this.#snapshotStatus.set(EMPTY_STATUS); + this.#pendingIntents = []; + this.#settledCell.set(true); + this.#commitState.set("idle"); + }); + } + + enqueueIntent(intent: FontIntent): void { + this.#pendingIntents.push(intent); + this.#settledCell.set(false); + if (this.#commitState.peek() === "idle") { + this.#commitState.set("queued"); + } + } + + hasPendingIntents(): boolean { + return this.#pendingIntents.length > 0; + } + + takePendingIntents(): FontIntent[] { + const intents = this.#pendingIntents; + this.#pendingIntents = []; + return intents; + } + + beginApplying(): void { + this.#commitState.set("applying"); + } + + markSettledIfIdle(busy: number): void { + if (busy === 0 && this.#pendingIntents.length === 0) { + this.#settledCell.set(true); + this.#commitState.set("idle"); + } + } + + markSnapshotsLoading(glyphIds: readonly GlyphId[]): number { + const generation = this.#generation; + const next = new Map(this.#snapshotStatus.peek()); + for (const glyphId of glyphIds) { + this.#snapshotGeneration.set(glyphId, generation); + next.set(glyphId, "loading"); + } + this.#snapshotStatus.set(next); + return generation; + } + + applyGlyphSnapshots( + requestedGlyphIds: readonly GlyphId[], + snapshots: readonly WorkspaceGlyphSnapshot[], + generation: number, + ): void { + if (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) !== 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 requestedGlyphIds) { + if (!received.has(glyphId) && this.#snapshotGeneration.get(glyphId) === generation) { + nextStatus.set(glyphId, "missing"); + } + } + + this.#snapshotStatus.set(nextStatus); + }); + } + + foldAppliedChange(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); + } + } + + if (staleGlyphIds.size > 0) { + const nextStatus = new Map(this.#snapshotStatus.peek()); + for (const glyphId of staleGlyphIds) { + const status = nextStatus.get(glyphId); + if (status === "loaded") nextStatus.set(glyphId, "stale"); + } + this.#snapshotStatus.set(nextStatus); + } + }); + } + + layerState(layerId: LayerId): GlyphLayerState | null { + return this.#layerStates.get(layerId) ?? null; + } + + layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { + const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); + return layerId ? (this.#layerStates.get(layerId) ?? null) : null; + } + + variationData(glyphId: GlyphId): GlyphVariationData | null { + return this.#variationDataByGlyph.get(glyphId) ?? null; + } + + 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)); + } + + 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]; + } + + #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); + } + + #indexWorkspace(snapshot: WorkspaceSnapshot | null): void { + this.#layerByGlyphSource.clear(); + this.#glyphByLayer.clear(); + if (!snapshot) return; + + for (const glyph of snapshot.glyphs) { + 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.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index fe816fde..4c86c11a 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; } @@ -1321,8 +1325,7 @@ export class Glyph { readonly #source: Source; readonly #layerState: GlyphLayerState; - readonly #variationData: GlyphVariationData | null; - readonly #glyphId: GlyphId | null; + readonly #glyphId: GlyphId; readonly #geometry: ComputedSignal; @@ -1333,34 +1336,29 @@ export class Glyph { constructor( font: Font, + glyphId: GlyphId, handle: GlyphHandle, source: Source, - state: GlyphState, - glyphId?: GlyphId, + state: GlyphLayerState, ) { this.handle = 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 name(): GlyphName { @@ -1451,14 +1449,17 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (exactSource) { - return this.#font.glyphLayer(this.handle, exactSource)?.geometry ?? emptyGlyphGeometry(); + return ( + this.#font.glyphLayerById(this.#glyphId, exactSource)?.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 +1478,7 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (!exactSource) return null; - return this.#font.glyphLayer(this.handle, exactSource); + return this.#font.glyphLayerById(this.#glyphId, exactSource); } /** @@ -1513,25 +1514,17 @@ export class Glyph { } /** @internal GlyphLayer caching is owned by Font.glyphLayer(). */ - createGlyphLayer(source: Source, state?: GlyphState | null): GlyphLayer | null { + 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 +1549,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..6de051d6 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; + glyphById(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.glyphById(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.glyphById(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/GlyphSnapshotRequests.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts new file mode 100644 index 00000000..6993091f --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts @@ -0,0 +1,95 @@ +import type { GlyphId, SourceId } from "@shift/types"; +import type { FontStore } from "./FontStore"; +import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; + +type SnapshotRequest = { + glyphId: GlyphId; + sourceIds: SourceId[]; +}; + +type InFlightKey = string & { readonly __inFlightKey: unique symbol }; + +export class GlyphSnapshotRequests { + readonly #store: FontStore; + readonly #edits: WorkspaceEditCoordinator; + readonly #inFlight = new Set(); + + constructor(store: FontStore, edits: WorkspaceEditCoordinator) { + this.#store = store; + this.#edits = edits; + } + + async load(glyphIds: readonly GlyphId[], sourceIds: readonly SourceId[]): Promise { + const queue = this.#requestable(glyphIds, sourceIds); + const seen = new Set(queue.map((request) => request.glyphId)); + + while (queue.length > 0) { + const batch = queue.splice(0); + for (const request of batch) this.#markInFlight(request); + + try { + await this.#edits.loadGlyphSnapshots(batch); + } finally { + for (const request of batch) this.#unmarkInFlight(request); + } + + for (const request of batch) { + for (const baseGlyphId of this.#store.loadedComponentBaseGlyphIds(request.glyphId)) { + if (seen.has(baseGlyphId)) continue; + const neededSourceIds = this.#neededSourceIds(baseGlyphId, sourceIds); + if (neededSourceIds.length === 0) continue; + seen.add(baseGlyphId); + queue.push({ glyphId: baseGlyphId, sourceIds: neededSourceIds }); + } + } + } + } + + #requestable(glyphIds: readonly GlyphId[], sourceIds: readonly SourceId[]): SnapshotRequest[] { + const result: SnapshotRequest[] = []; + const seen = new Set(); + for (const glyphId of glyphIds) { + if (seen.has(glyphId)) continue; + const neededSourceIds = this.#neededSourceIds(glyphId, sourceIds); + if (neededSourceIds.length === 0) continue; + seen.add(glyphId); + result.push({ glyphId, sourceIds: neededSourceIds }); + } + return result; + } + + #neededSourceIds(glyphId: GlyphId, sourceIds: readonly SourceId[]): SourceId[] { + const status = this.#store.snapshotStatus(glyphId); + const stale = status === "stale"; + const needed: SourceId[] = []; + const seen = new Set(); + + for (const sourceId of sourceIds) { + if (seen.has(sourceId)) continue; + seen.add(sourceId); + if (!this.#store.hasLayerRecord(glyphId, sourceId)) continue; + if (this.#inFlight.has(inFlightKey(glyphId, sourceId))) continue; + if (stale || !this.#store.hasLayerSnapshot(glyphId, sourceId)) { + needed.push(sourceId); + } + } + + return needed; + } + + #markInFlight(request: SnapshotRequest): void { + for (const sourceId of request.sourceIds) { + this.#inFlight.add(inFlightKey(request.glyphId, sourceId)); + } + } + + #unmarkInFlight(request: SnapshotRequest): void { + for (const sourceId of request.sourceIds) { + this.#inFlight.delete(inFlightKey(request.glyphId, sourceId)); + } + } +} + +function inFlightKey(glyphId: GlyphId, sourceId: SourceId): InFlightKey { + return `${glyphId}:${sourceId}` as InFlightKey; +} 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..eabc1c30 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,30 @@ 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) { + await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id]); + const glyph = stack.font.glyphById(glyphId); + if (!glyph) throw new Error("Expected default glyph layer to load"); + return glyph; +} + +async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { + await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id, source.id]); + const layer = stack.font.glyphLayerById(glyphId, source); + 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 +158,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 +179,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 +194,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/testUtils.ts b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts index f7b3470f..b3ab3911 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -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,8 +44,10 @@ 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.glyphSnapshots.load([record.id], [stack.font.defaultSource.id]); } const handle = stack.font.glyphHandleForUnicode(65 as Unicode); 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..da15806a 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -21,23 +21,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 }); }); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index 29158de7..f53c72d5 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -1,15 +1,13 @@ -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, + WorkspaceGlyphSnapshotRequest, +} from "@shared/workspace/protocol"; +import type { Signal } 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 +17,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 +27,15 @@ 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; - #queue: FontIntent[] = []; #flushQueued = false; #chain: Promise = Promise.resolve(); #busy = 0; - constructor(workspace: WorkspaceClient) { + constructor(workspace: WorkspaceClient, store: FontStore) { this.#workspace = workspace; + this.#store = store; } /** @@ -50,7 +43,7 @@ export class WorkspaceEditCoordinator { * indicator: un-echoed state must never read as durable. */ get settledCell(): Signal { - return this.#settledCell; + return this.#store.settledCell; } /** @@ -62,21 +55,12 @@ export class WorkspaceEditCoordinator { * the utility process has echoed the new dirty state. */ get commitStateCell(): Signal { - return this.#commitState; - } - - /** Routes one layer's echoes to its session state. */ - register(layerId: LayerId, target: FoldTarget): void { - this.#targets.set(layerId, target); + return this.#store.commitStateCell; } /** Queues one intent; everything in the same microtask becomes one apply. */ push(intent: FontIntent): void { - this.#queue.push(intent); - this.#settledCell.set(false); - if (this.#commitState.peek() === "idle") { - this.#commitState.set("queued"); - } + this.#store.enqueueIntent(intent); if (!this.#flushQueued) { this.#flushQueued = true; @@ -86,31 +70,44 @@ 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.#store.hasPendingIntents() || 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.foldAppliedChange(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.foldAppliedChange(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 loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { + if (requests.length === 0) return; + + const glyphIds = requests.map((request) => request.glyphId); + const generation = this.#store.markSnapshotsLoading(glyphIds); + const snapshots = await this.#withFlush(() => this.#workspace.glyphSnapshots(requests)); + this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); } /** 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.foldAppliedChange(applied); return applied; }); } @@ -138,16 +135,15 @@ export class WorkspaceEditCoordinator { #enqueueFlush(): void { this.#flushQueued = false; - if (this.#queue.length === 0) return; + if (!this.#store.hasPendingIntents()) return; - const intents = this.#queue; - this.#queue = []; + const intents = this.#store.takePendingIntents(); void this.#serialize(async () => { try { - this.#commitState.set("applying"); + this.#store.beginApplying(); const applied = await this.#workspace.apply(intents); - this.#fold(applied); + this.#store.foldAppliedChange(applied); } catch (error) { console.error("workspace apply failed; resyncing from truth", error); await this.#resync(); @@ -169,37 +165,11 @@ export class WorkspaceEditCoordinator { #afterJob(): void { this.#busy -= 1; - if (this.#busy === 0 && this.#queue.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); - } - } + this.#store.markSettledIfIdle(this.#busy); } - /** 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..76d174df 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.loadGlyphSnapshots([ + { 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..3181d39a 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -78,7 +78,8 @@ export class TestEditor extends Editor { 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 +93,7 @@ export class TestEditor extends Editor { createGlyphLayer: { layerId: mintLayerId(), glyphId, - sourceId: this.font.defaultSource.id, + sourceId, }, }, ]); @@ -100,8 +101,9 @@ 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"); + await this.#stack.glyphSnapshots.load([record.id], [sourceId]); + const glyph = this.font.glyphById(record.id); + if (!glyph) throw new Error("glyphById returned null for a loaded glyph"); return glyph; } diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index e761cea2..603a9d3f 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -5,13 +5,17 @@ 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 { GlyphSnapshotRequests } from "@/lib/model/GlyphSnapshotRequests"; 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; + glyphSnapshots: GlyphSnapshotRequests; font: Font; createWorkspace(): Promise; }; @@ -19,7 +23,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 +44,27 @@ 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 glyphSnapshots = new GlyphSnapshotRequests(store, editCoordinator); + const font = new Font(store, editCoordinator); return { client, + store, editCoordinator, + glyphSnapshots, 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..f63cc3fe 100644 --- a/apps/desktop/src/renderer/src/workspace/Workspace.ts +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -3,6 +3,8 @@ 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 { GlyphSnapshotRequests } from "@/lib/model/GlyphSnapshotRequests"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { @@ -19,25 +21,29 @@ export interface WorkspaceOptions { export class Workspace { readonly #client: WorkspaceClient; + readonly #store: FontStore; readonly #edits: WorkspaceEditCoordinator; readonly #documentBridge: WorkspaceDocumentBridge; #connection: Promise | null = null; readonly font: Font; readonly editor: Editor; + readonly glyphSnapshots: GlyphSnapshotRequests; readonly documentStateCell: Signal; readonly commitStateCell: Signal; 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.glyphSnapshots = new GlyphSnapshotRequests(this.#store, this.#edits); this.documentStateCell = this.#client.documentStateCell; this.commitStateCell = this.#edits.commitStateCell; @@ -67,6 +73,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..83b02cd9 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -7,6 +7,7 @@ import type { SyncEventMap, WorkspaceDocumentSourceKind, WorkspaceDocumentState, + WorkspaceGlyphSnapshot, WorkspaceSnapshot, } from "../../shared/workspace/protocol"; import { DocumentStorage } from "./DocumentStorage"; @@ -100,7 +101,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[]), }); } 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-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/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 { From 364d562b9474fc9c4af604e6ca8396a288365690 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 21:47:21 +0000 Subject: [PATCH 02/14] Tighten glyph snapshot route ownership --- .../renderer/src/components/editor/Canvas.tsx | 39 +---- .../renderer/src/lib/editor/Editor.test.ts | 32 ++++ .../src/renderer/src/lib/editor/Editor.ts | 102 ++++++++++++- .../renderer/src/lib/editor/EditorState.ts | 5 +- .../src/lib/editor/rendering/RenderFrame.ts | 34 ++++- .../src/renderer/src/lib/model/Font.test.ts | 49 ++++-- .../src/renderer/src/lib/model/Font.ts | 139 +++++++----------- .../src/renderer/src/lib/model/FontStore.ts | 49 ++++++ .../src/renderer/src/lib/model/Glyph.ts | 17 ++- .../renderer/src/lib/model/GlyphOutline.ts | 6 +- ...shotRequests.ts => GlyphSnapshotLoader.ts} | 44 +++++- .../renderer/src/lib/model/variation.test.ts | 10 +- .../renderer/src/lib/text/layout/testUtils.ts | 2 +- .../src/renderer/src/testing/TestEditor.ts | 20 +-- .../renderer/src/testing/workspaceStack.ts | 8 +- .../src/renderer/src/workspace/Workspace.ts | 12 +- 16 files changed, 377 insertions(+), 191 deletions(-) rename apps/desktop/src/renderer/src/lib/model/{GlyphSnapshotRequests.ts => GlyphSnapshotLoader.ts} (62%) diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index ac918fd8..2f40f717 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -4,7 +4,7 @@ import { useParams } from "react-router-dom"; import { CanvasContextProvider } from "@/context/CanvasContext"; import { useDebugSafe } from "@/context/DebugContext"; import { useSignalState } from "@/lib/signals"; -import { useEditor, useWorkspace } from "@/workspace/WorkspaceContext"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; import { StaticScene } from "./StaticScene"; @@ -14,7 +14,6 @@ import { Vec2 } from "@shift/geo"; import { asGlyphId } from "@shift/types"; export const Canvas: FC = () => { - const workspace = useWorkspace(); const editor = useEditor(); const debug = useDebugSafe(); const { glyphId: glyphIdParam } = useParams(); @@ -24,39 +23,9 @@ export const Canvas: FC = () => { const fontLoaded = useSignalState(editor.font.$loaded); useEffect(() => { - if (!glyphIdParam) { - editor.scene.clear(); - return; - } - - const glyphId = asGlyphId(glyphIdParam); - if (!editor.font.hasGlyph(glyphId)) { - editor.scene.clear(); - return; - } - - const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - editor.scene.setGeometryItems([itemId]); - - let cancelled = false; - - async function loadGlyphSnapshot(): Promise { - try { - await workspace.glyphSnapshots.load([glyphId], [editor.font.defaultSource.id]); - if (cancelled) return; - editor.focusGlyph(glyphId, editor.font.defaultLocation()); - } catch (error) { - console.error("failed to load glyph snapshot", error); - } - } - - void loadGlyphSnapshot(); - - return () => { - cancelled = true; - editor.scene.clear(); - }; - }, [editor, fontLoaded, glyphIdParam, workspace]); + const route = editor.openGlyphRoute(glyphIdParam ? asGlyphId(glyphIdParam) : null); + return () => route.close(); + }, [editor, fontLoaded, glyphIdParam]); useEffect(() => { const element = containerRef.current; 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..059d516b 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,38 @@ describe("Editor", () => { expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); }); + it("opens route glyphs through the editor route session", async () => { + const record = editor.font.recordForName("S")!; + + const session = editor.openGlyphRoute(record.id); + const itemId = editor.scene.value.items[0]?.id ?? null; + + 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(itemId && editor.scene.isGeometryShown(itemId)).toBe(true); + + const glyph = await session.ready; + + expect(glyph?.id).toBe(record.id); + expect(editor.glyph.peek()).toBe(glyph); + expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); + }); + + it("cancels stale route sessions before they focus", async () => { + const record = editor.font.recordForName("S")!; + + const session = editor.openGlyphRoute(record.id); + session.close(); + + expect(await session.ready).toBeNull(); + 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(); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 4aa9147b..db2087c9 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -75,6 +75,7 @@ import { TextRuns } from "@/lib/text/TextRuns"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; import { glyphTextItem, Positioner } from "@/lib/text/layout"; import type { GlyphAnchor } from "@/lib/text/layout"; +import type { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; @@ -97,9 +98,25 @@ import { interface EditorOptions { font: Font; + glyphSnapshotLoader?: GlyphSnapshotLoader; clipboard: SystemClipboard; } +/** + * Represents one route-owned glyph opening lifecycle. + * + * @remarks + * Closing the session cancels stale async focus work and clears the editor only + * while this session is still the active route. + */ +export interface GlyphRouteSession { + /** Resolves with the focused glyph, or null when the route is missing or cancelled. */ + readonly ready: Promise; + + /** Cancels this route session if it is still current. */ + close(): void; +} + /** * Central orchestrator for the glyph editing surface. * @@ -144,6 +161,7 @@ export class Editor { readonly hover: Hover; readonly font: Font; readonly scene: Scene; + readonly #glyphSnapshotLoader: GlyphSnapshotLoader | null; /** * Rendering and camera infrastructure. @@ -197,6 +215,7 @@ export class Editor { #textRuns: TextRuns; #glyphFinderOpen: WritableSignal; + #glyphRouteGeneration = 0; #zone: FocusZone = "canvas"; @@ -225,6 +244,7 @@ export class Editor { this.#camera = new Camera(); this.font = options.font; + this.#glyphSnapshotLoader = options.glyphSnapshotLoader ?? null; this.scene = new Scene(); this.#glyph = new EditorGlyphState(this.font, this.scene.locationCell); @@ -545,10 +565,65 @@ export class Editor { } const handle = this.font.glyphHandleForName(record.name); + const glyph = this.font.glyphForId(glyphId); + if (!glyph) { + this.#glyph.open.glyph.set(null); + this.#glyph.open.rootHandle.set(handle); + return null; + } + this.#glyph.open.rootHandle.set(handle); this.scene.setLocation(location); this.#glyph.layerEditing.followDesignLocation(); - return this.#focusGlyphHandle(handle); + this.#glyph.open.glyph.set(glyph); + return glyph; + } + + /** + * Opens a route-selected glyph in the scene and focuses it after geometry loads. + * + * @remarks + * The scene item is placed synchronously so guides and route state update + * immediately. Geometry hydration runs through the glyph snapshot loader; if a + * later route opens before it resolves, the stale completion is ignored. + * + * @param glyphId - document glyph identity from the editor route, or null to clear. + * @returns route session handle for cancellation and optional readiness awaits. + */ + public openGlyphRoute(glyphId: GlyphId | null): GlyphRouteSession { + const generation = ++this.#glyphRouteGeneration; + const close = () => this.#closeGlyphRoute(generation); + + if (!glyphId) { + this.close(); + return { ready: Promise.resolve(null), close }; + } + + const record = this.font.recordForId(glyphId); + if (!record) { + this.close(); + return { ready: Promise.resolve(null), close }; + } + + const location = this.font.defaultLocation(); + const handle = this.font.glyphHandleForName(record.name); + batch(() => { + this.scene.clear(); + this.scene.setLocation(location); + const itemId = this.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); + this.scene.setGeometryItems([itemId]); + this.#glyph.open.rootHandle.set(handle); + this.#glyph.open.glyph.set(null); + this.#glyph.layerEditing.followDesignLocation(); + }); + + const ready = this.#loadRouteGlyph(glyphId, location, generation).catch((error) => { + if (this.#glyphRouteGeneration === generation) { + console.error("failed to open glyph route", error); + } + return null; + }); + return { ready, close }; } /** @@ -610,6 +685,22 @@ export class Editor { this.#glyph.open.activeContourId.set(null); } + async #loadRouteGlyph( + glyphId: GlyphId, + location: AxisLocation, + generation: number, + ): Promise { + await this.#glyphSnapshotLoader?.load([glyphId]); + if (this.#glyphRouteGeneration !== generation) return null; + return this.focusGlyph(glyphId, location); + } + + #closeGlyphRoute(generation: number): void { + if (this.#glyphRouteGeneration !== generation) return; + this.#glyphRouteGeneration += 1; + this.close(); + } + public get $designLocation(): Signal { return this.scene.locationCell; } @@ -775,10 +866,7 @@ 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 { @@ -793,9 +881,7 @@ export class Editor { if (!item) return null; const source = this.font.sourceAt(this.scene.location); 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. */ diff --git a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts index 9b3d6cca..8df396e4 100644 --- a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts +++ b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts @@ -259,8 +259,7 @@ export class GlyphLayerEditingState { const glyph = open.glyph.value; if (!glyph) 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(glyph.id, source.id)?.id ?? null; return { sourceId: source.id, layerId }; }, { name: "editor.glyph.layerEditing.layer" }, @@ -275,7 +274,7 @@ export class GlyphLayerEditingState { if (!source) return null; if (this.layer.value?.layerId === null) return null; - return font.glyphLayer(glyph.handle, source); + return font.glyphLayerForId(glyph.id, source.id); }, { name: "editor.glyph.layerEditing.glyphLayer" }, ); 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 3a34d1d2..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"; @@ -24,6 +25,12 @@ interface BackgroundGlyphFrame { readonly geometryShown: boolean; } +interface GuideAdvanceInput { + readonly record: GlyphRecord; + readonly model: Glyph | null; + readonly xAdvance: number; +} + export interface BackgroundLayerProps { readonly glyphs: readonly BackgroundGlyphFrame[]; } @@ -107,15 +114,13 @@ export class BackgroundLayer extends CanvasItem { const glyph = this.#editor.glyphForItem(item.id); const instance = this.#editor.instanceForItem(item.id); - const xAdvance = instance?.xAdvanceCell.value ?? this.#editor.font.defaultXAdvance; - const unicode = glyph - ? Number.isFinite(glyph.unicode) - ? glyph.unicode - : null - : (record.unicodes[0] ?? null); glyphs.push({ item, - advance: displayAdvance(xAdvance, record.name, unicode), + advance: guideAdvance({ + record, + model: glyph, + xAdvance: instance?.xAdvanceCell.value ?? this.#editor.font.defaultXAdvance, + }), geometryShown, }); } @@ -137,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/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index 56c0301c..16911df2 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -149,7 +149,7 @@ 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 () => { @@ -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,13 +190,40 @@ 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]); - await stack.glyphSnapshots.load([record.id], [source.id]); - const glyph = stack.font.glyphById(record.id); + await stack.glyphSnapshotLoader.load([record.id], { sourceIds: [source.id] }); + const glyph = stack.font.glyphForId(record.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(); + await stack.glyphSnapshotLoader.load([record.id]); + + const glyph = stack.font.glyphForId(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(); @@ -245,8 +272,8 @@ describe("font-level intents make the font variable", () => { }, ]); - await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id]); - const glyph = stack.font.glyphById(glyphId); + await stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [stack.font.defaultSource.id] }); + const glyph = stack.font.glyphForId(glyphId); if (!glyph) throw new Error("Expected default glyph layer to open"); expect(glyph.xAdvance).toBe(640); @@ -313,11 +340,11 @@ describe("font-level intents make the font variable", () => { const boldSource = stack.font.source(boldSourceId); if (!regularSource || !boldSource) throw new Error("Expected both sources"); - const regularLoad = stack.glyphSnapshots.load([glyphId], [defaultSourceId]); - const boldLoad = stack.glyphSnapshots.load([glyphId], [boldSourceId]); + const regularLoad = stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [defaultSourceId] }); + const boldLoad = stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [boldSourceId] }); await Promise.all([regularLoad, boldLoad]); - expect(stack.font.glyphLayerById(glyphId, regularSource)?.id).toBe(defaultLayerId); - expect(stack.font.glyphLayerById(glyphId, boldSource)?.id).toBe(boldLayerId); + 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 3b8a89f3..a06c9245 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -264,8 +264,6 @@ class GlyphDirectory { } } -type GlyphLayerKey = string & { readonly __glyphLayerKey: unique symbol }; - const DEFAULT_FONT_METRICS: FontMetrics = { unitsPerEm: 1000, ascender: 800, @@ -275,12 +273,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 @@ -298,12 +296,7 @@ export class Font { readonly #directory: Signal; readonly #store: FontStore; - readonly #glyphs = new Map(); - /** Open glyph models keyed by stable id; survives directory re-keys. */ - readonly #glyphsById = new Map(); - readonly #glyphLayers = new Map(); readonly #editCoordinator: WorkspaceEditCoordinator | null; - #documentId: string | null = null; /** * Projects the renderer's workspace snapshot into the font domain model. @@ -412,7 +405,7 @@ export class Font { * @knipclassignore */ hasGlyph(glyphId: GlyphId): boolean { - return this.#directory.peek().hasGlyph(glyphId); + return this.#store.hasGlyph(glyphId); } /** @@ -428,12 +421,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); } /** @@ -574,8 +562,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); } /** @@ -615,49 +603,44 @@ export class Font { } /** - * Get the cached model for an existing glyph. + * Returns the local glyph model for a name-based handle. * - * 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) - * ``` + * @remarks + * This is a pure local read. It does not request geometry; callers that need + * missing snapshots should use the glyph snapshot loader before expecting a + * model. * - * @returns The glyph model, or `null` when the glyph has no state for the default source. + * @param handle - glyph identity from a text, catalog, or finder flow. + * @returns the glyph model, or null when the glyph is missing or not loaded. */ glyph(handle: GlyphHandle): Glyph | null { const record = this.#directory.peek().recordForName(handle.name); - return record ? this.glyphById(record.id) : null; + return record ? this.glyphForId(record.id) : null; } - glyphById(glyphId: GlyphId): Glyph | null { + /** + * Returns the local glyph model for a stable glyph id. + * + * @param glyphId - document glyph identity to resolve. + * @returns the id-keyed glyph model, or null when the glyph is missing or not loaded. + */ + glyphForId(glyphId: GlyphId): Glyph | null { if (!this.loaded) return null; - this.#clearDocumentModelsIfNeeded(); - const directory = this.#directory.peek(); - const record = directory.recordForId(glyphId); + const record = this.#store.recordForId(glyphId); if (!record) return null; - const cached = this.#glyphsById.get(glyphId); - if (cached) return cached; + return this.#store.glyphModel(glyphId, () => { + const source = this.defaultSource; + const layer = directory.layerForGlyphAtSource(glyphId, source.id); + if (!layer) return null; - const source = this.defaultSource; - const layer = directory.layerForGlyphAtSource(glyphId, source.id); - if (!layer) return null; + const state = this.layerState(layer.id); + if (!state) return null; - const state = this.layerState(layer.id); - if (!state) return null; - - const handle = directory.glyphHandleForName(record.name); - const glyph = new Glyph(this, glyphId, handle, source, state); - this.#glyphsById.set(glyphId, glyph); - this.#glyphs.set(record.name, glyph); - return glyph; + return new Glyph(this, glyphId, directory.glyphHandleForName(record.name), source, state); + }); } /** @@ -677,29 +660,30 @@ export class Font { */ glyphLayer(handle: GlyphHandle, source: Source): GlyphLayer | null { const record = this.#directory.peek().recordForName(handle.name); - return record ? this.glyphLayerById(record.id, source) : null; + return record ? this.glyphLayerForId(record.id, source.id) : null; } - glyphLayerById(glyphId: GlyphId, source: Source): GlyphLayer | null { - if (!this.source(source.id)) return 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; - this.#clearDocumentModelsIfNeeded(); - const layer = this.#directory.peek().layerForGlyphAtSource(glyphId, source.id); + const layer = this.#store.layerRecordForId(glyphId, source.id); if (!layer) return null; - const key = glyphLayerModelKey(glyphId, source.id); - const cached = this.#glyphLayers.get(key); - if (cached) return cached; - - const glyph = this.glyphById(glyphId); - if (!glyph) return null; - - const state = glyph.isPrimarySource(source) ? undefined : this.layerState(layer.id); - const glyphLayer = glyph.createGlyphLayer(source, state); - if (!glyphLayer) return null; + return this.#store.glyphLayerModel(glyphId, source.id, () => { + const glyph = this.glyphForId(glyphId); + if (!glyph) return null; - this.#glyphLayers.set(key, glyphLayer); - return glyphLayer; + const state = glyph.isPrimarySource(source) ? undefined : this.layerState(layer.id); + return glyph.createGlyphLayer(source, state); + }); } /** @@ -715,10 +699,10 @@ 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): GlyphLayerState | null { return this.#store.layerState(layerId); @@ -892,26 +876,11 @@ export class Font { return this.#$sources.peek(); } - #clearDocumentModelsIfNeeded(): void { - const workspace = this.#store.workspaceCell.peek(); - const documentId = workspace?.documentId ?? null; - if (this.#documentId !== documentId) { - this.#glyphs.clear(); - this.#glyphsById.clear(); - this.#glyphLayers.clear(); - this.#documentId = documentId; - } - } - defaultLocation(): AxisLocation { return this.isVariable() ? defaultAxisLocation(this.getAxes()) : emptyAxisLocation(); } } -function glyphLayerModelKey(glyphId: GlyphId, sourceId: SourceId): GlyphLayerKey { - return `${glyphId}:${sourceId}` as GlyphLayerKey; -} - function glyphLayerKey(glyphId: GlyphId, sourceId: SourceId): string { return `${glyphId}:${sourceId}`; } diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index 52eca681..9500dab2 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -2,6 +2,8 @@ import type { AppliedChange, FontIntent, GlyphId, + GlyphLayerRecord, + GlyphRecord, GlyphStructure, GlyphState, GlyphVariationData, @@ -15,6 +17,7 @@ import type { } 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"; export type WorkspaceCommitState = "idle" | "queued" | "applying"; @@ -38,6 +41,9 @@ export class FontStore { 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(); @@ -76,6 +82,8 @@ export class FontStore { 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); @@ -222,6 +230,22 @@ export class FontStore { 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; + } + + sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { + return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; + } + layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); return layerId ? (this.#layerStates.get(layerId) ?? null) : null; @@ -243,6 +267,29 @@ export class FontStore { return this.#layerByGlyphSource.has(glyphSourceKey(glyphId, sourceId)); } + 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; + } + loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { const baseGlyphIds = new Set(); for (const state of this.#loadedLayerStatesForGlyph(glyphId)) { @@ -287,9 +334,11 @@ export class FontStore { #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); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index 4c86c11a..55323eff 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -1319,10 +1319,9 @@ class ContourCache { * location. */ export class Glyph { - readonly handle: GlyphHandle; - readonly #font: Font; readonly #source: Source; + readonly #fallbackHandle: GlyphHandle; readonly #layerState: GlyphLayerState; readonly #glyphId: GlyphId; @@ -1341,7 +1340,7 @@ export class Glyph { source: Source, state: GlyphLayerState, ) { - this.handle = handle; + this.#fallbackHandle = handle; this.#font = font; this.#source = source; this.#glyphId = glyphId; @@ -1361,6 +1360,14 @@ export class Glyph { 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 { return this.handle.name; } @@ -1450,7 +1457,7 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (exactSource) { return ( - this.#font.glyphLayerById(this.#glyphId, exactSource)?.geometry ?? emptyGlyphGeometry() + this.#font.glyphLayerForId(this.#glyphId, exactSource.id)?.geometry ?? emptyGlyphGeometry() ); } @@ -1478,7 +1485,7 @@ export class Glyph { const exactSource = this.#font.sourceAt(location); if (!exactSource) return null; - return this.#font.glyphLayerById(this.#glyphId, exactSource); + return this.#font.glyphLayerForId(this.#glyphId, exactSource.id); } /** diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts index 6de051d6..82bddd35 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphOutline.ts @@ -8,7 +8,7 @@ import type { ContourData, GlyphId } from "@shift/types"; import type { LayerContourCoordinates } from "./GlyphLayerState"; interface GlyphResolver { - glyphById(glyphId: GlyphId): Glyph | null; + glyphForId(glyphId: GlyphId): Glyph | null; } type OutlineCommand = @@ -390,7 +390,7 @@ class GlyphOutlineBuilder { const componentValues = coordinates.components[index]; if (!componentValues) continue; - const componentGlyph = this.#resolver.glyphById(component.baseGlyphId); + const componentGlyph = this.#resolver.glyphForId(component.baseGlyphId); if (!componentGlyph) continue; track(componentValues.matrix); @@ -407,7 +407,7 @@ class GlyphOutlineBuilder { } for (const component of geometry.components) { - const componentGlyph = this.#resolver.glyphById(component.baseGlyphId); + const componentGlyph = this.#resolver.glyphForId(component.baseGlyphId); if (!componentGlyph) continue; const child = this.#collect(componentGlyph, Mat.Compose(matrix, component.matrix)); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts similarity index 62% rename from apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts rename to apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts index 6993091f..6cf20b24 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotRequests.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts @@ -7,9 +7,20 @@ type SnapshotRequest = { sourceIds: SourceId[]; }; +export type GlyphSnapshotLoadOptions = { + readonly sourceIds?: readonly SourceId[]; +}; + type InFlightKey = string & { readonly __inFlightKey: unique symbol }; -export class GlyphSnapshotRequests { +/** + * Coordinates renderer glyph snapshot reads through the workspace sync lane. + * + * @remarks + * Loaded geometry and freshness state remain in {@link FontStore}; this loader + * only dedupes requests, chooses source scopes, and follows component bases. + */ +export class GlyphSnapshotLoader { readonly #store: FontStore; readonly #edits: WorkspaceEditCoordinator; readonly #inFlight = new Set(); @@ -19,8 +30,14 @@ export class GlyphSnapshotRequests { this.#edits = edits; } - async load(glyphIds: readonly GlyphId[], sourceIds: readonly SourceId[]): Promise { - const queue = this.#requestable(glyphIds, sourceIds); + /** + * Loads missing or stale snapshots for the requested glyphs. + * + * @param glyphIds - stable glyph identities whose local geometry should be available. + * @param options - optional source scope; omitted means every authored layer for each glyph. + */ + async load(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions = {}): Promise { + const queue = this.#requestable(glyphIds, options); const seen = new Set(queue.map((request) => request.glyphId)); while (queue.length > 0) { @@ -36,7 +53,7 @@ export class GlyphSnapshotRequests { for (const request of batch) { for (const baseGlyphId of this.#store.loadedComponentBaseGlyphIds(request.glyphId)) { if (seen.has(baseGlyphId)) continue; - const neededSourceIds = this.#neededSourceIds(baseGlyphId, sourceIds); + const neededSourceIds = this.#neededSourceIds(baseGlyphId, options); if (neededSourceIds.length === 0) continue; seen.add(baseGlyphId); queue.push({ glyphId: baseGlyphId, sourceIds: neededSourceIds }); @@ -45,12 +62,24 @@ export class GlyphSnapshotRequests { } } - #requestable(glyphIds: readonly GlyphId[], sourceIds: readonly SourceId[]): SnapshotRequest[] { + /** + * Starts a background snapshot load and logs failures. + * + * @param glyphIds - stable glyph identities whose local geometry should be requested. + * @param options - optional source scope; omitted means every authored layer for each glyph. + */ + request(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions = {}): void { + void this.load(glyphIds, options).catch((error) => { + console.error("failed to load glyph snapshots", error); + }); + } + + #requestable(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions): SnapshotRequest[] { const result: SnapshotRequest[] = []; const seen = new Set(); for (const glyphId of glyphIds) { if (seen.has(glyphId)) continue; - const neededSourceIds = this.#neededSourceIds(glyphId, sourceIds); + const neededSourceIds = this.#neededSourceIds(glyphId, options); if (neededSourceIds.length === 0) continue; seen.add(glyphId); result.push({ glyphId, sourceIds: neededSourceIds }); @@ -58,11 +87,12 @@ export class GlyphSnapshotRequests { return result; } - #neededSourceIds(glyphId: GlyphId, sourceIds: readonly SourceId[]): SourceId[] { + #neededSourceIds(glyphId: GlyphId, options: GlyphSnapshotLoadOptions): SourceId[] { const status = this.#store.snapshotStatus(glyphId); const stale = status === "stale"; const needed: SourceId[] = []; const seen = new Set(); + const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); for (const sourceId of sourceIds) { if (seen.has(sourceId)) continue; 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 eabc1c30..37645625 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -135,15 +135,17 @@ async function variableFont(): Promise<{ } async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { - await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id]); - const glyph = stack.font.glyphById(glyphId); + await stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [stack.font.defaultSource.id] }); + const glyph = stack.font.glyphForId(glyphId); if (!glyph) throw new Error("Expected default glyph layer to load"); return glyph; } async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { - await stack.glyphSnapshots.load([glyphId], [stack.font.defaultSource.id, source.id]); - const layer = stack.font.glyphLayerById(glyphId, source); + await stack.glyphSnapshotLoader.load([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; } 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 b3ab3911..b56991ae 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -47,7 +47,7 @@ export async function layoutTestFont(): Promise { await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId, width: advance } }, ]); - await stack.glyphSnapshots.load([record.id], [stack.font.defaultSource.id]); + await stack.glyphSnapshotLoader.load([record.id], { sourceIds: [stack.font.defaultSource.id] }); } const handle = stack.font.glyphHandleForUnicode(65 as Unicode); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 3181d39a..5aaf3f1e 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -43,7 +43,7 @@ export class TestEditor extends Editor { constructor() { const stack = createWorkspaceStack(); const clipboard = new InMemorySystemClipboard(); - super({ font: stack.font, clipboard }); + super({ font: stack.font, glyphSnapshotLoader: stack.glyphSnapshotLoader, clipboard }); this.#stack = stack; this.#clipboard = clipboard; registerBuiltInTools(this); @@ -59,19 +59,11 @@ 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]); + await this.openGlyphRoute(record.id).ready; 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); } @@ -101,9 +93,9 @@ 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"); - await this.#stack.glyphSnapshots.load([record.id], [sourceId]); - const glyph = this.font.glyphById(record.id); - if (!glyph) throw new Error("glyphById returned null for a loaded glyph"); + await this.#stack.glyphSnapshotLoader.load([record.id], { sourceIds: [sourceId] }); + const glyph = this.font.glyphForId(record.id); + if (!glyph) throw new Error("glyphForId returned null for a loaded glyph"); return glyph; } diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index 603a9d3f..4cd1f83f 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -7,7 +7,7 @@ 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 { GlyphSnapshotRequests } from "@/lib/model/GlyphSnapshotRequests"; +import { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; @@ -15,7 +15,7 @@ export type WorkspaceStack = { client: WorkspaceClient; store: FontStore; editCoordinator: WorkspaceEditCoordinator; - glyphSnapshots: GlyphSnapshotRequests; + glyphSnapshotLoader: GlyphSnapshotLoader; font: Font; createWorkspace(): Promise; }; @@ -46,14 +46,14 @@ export function createWorkspaceStack(): WorkspaceStack { }); const store = new FontStore(); const editCoordinator = new WorkspaceEditCoordinator(client, store); - const glyphSnapshots = new GlyphSnapshotRequests(store, editCoordinator); + const glyphSnapshotLoader = new GlyphSnapshotLoader(store, editCoordinator); const font = new Font(store, editCoordinator); return { client, store, editCoordinator, - glyphSnapshots, + glyphSnapshotLoader, font, async createWorkspace(): Promise { await shell.call("workspace.create", undefined); diff --git a/apps/desktop/src/renderer/src/workspace/Workspace.ts b/apps/desktop/src/renderer/src/workspace/Workspace.ts index f63cc3fe..a3770db8 100644 --- a/apps/desktop/src/renderer/src/workspace/Workspace.ts +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -4,7 +4,7 @@ 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 { GlyphSnapshotRequests } from "@/lib/model/GlyphSnapshotRequests"; +import { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { @@ -28,7 +28,7 @@ export class Workspace { readonly font: Font; readonly editor: Editor; - readonly glyphSnapshots: GlyphSnapshotRequests; + readonly glyphSnapshotLoader: GlyphSnapshotLoader; readonly documentStateCell: Signal; readonly commitStateCell: Signal; @@ -42,8 +42,12 @@ export class Workspace { }); this.font = new Font(this.#store, this.#edits); - this.editor = new Editor({ font: this.font, clipboard: options.clipboard }); - this.glyphSnapshots = new GlyphSnapshotRequests(this.#store, this.#edits); + this.glyphSnapshotLoader = new GlyphSnapshotLoader(this.#store, this.#edits); + this.editor = new Editor({ + font: this.font, + glyphSnapshotLoader: this.glyphSnapshotLoader, + clipboard: options.clipboard, + }); this.documentStateCell = this.#client.documentStateCell; this.commitStateCell = this.#edits.commitStateCell; From 4cc5e718d495c4310ca47d01ac61fea3ea32bd1e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 21:56:22 +0000 Subject: [PATCH 03/14] Tighten FontStore API boundaries --- .../src/renderer/src/lib/model/Font.test.ts | 6 +- .../src/renderer/src/lib/model/FontStore.ts | 124 ++++++++++++------ .../src/lib/model/GlyphSnapshotLoader.ts | 6 +- .../lib/workspace/WorkspaceEditCoordinator.ts | 6 +- .../renderer/src/testing/workspaceStack.ts | 6 +- .../src/renderer/src/workspace/Workspace.ts | 6 +- 6 files changed, 97 insertions(+), 57 deletions(-) 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 16911df2..14899f8c 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -52,7 +52,7 @@ describe("Font projects the workspace snapshot", () => { const store = new FontStore(); const font = new Font(store); - store.replaceWorkspace(SNAPSHOT); + store.sync.replaceWorkspace(SNAPSHOT); expect(font.loaded).toBe(true); expect(font.metrics.unitsPerEm).toBe(2048); @@ -70,7 +70,7 @@ describe("Font projects the workspace snapshot", () => { expect(font.$loaded.value).toBe(false); - store.replaceWorkspace(SNAPSHOT); + store.sync.replaceWorkspace(SNAPSHOT); expect(font.$loaded.value).toBe(true); }); @@ -81,7 +81,7 @@ describe("Font projects the workspace snapshot", () => { expect(font.loaded).toBe(true); - store.replaceWorkspace(null); + store.sync.replaceWorkspace(null); expect(font.loaded).toBe(false); expect(font.metrics.unitsPerEm).toBe(1000); diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index 9500dab2..6181367f 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -26,9 +26,42 @@ type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; const EMPTY_STATUS: ReadonlyMap = new Map(); +/** Mutates renderer-local font state from the serialized workspace sync lane. */ +export interface FontStoreSyncPort { + readonly settledCell: Signal; + readonly commitStateCell: Signal; + + replaceWorkspace(snapshot: WorkspaceSnapshot | null): void; + enqueueIntent(intent: FontIntent): void; + hasPendingIntents(): boolean; + takePendingIntents(): FontIntent[]; + beginApplying(): void; + markSettledIfIdle(busy: number): void; + markSnapshotsLoading(glyphIds: readonly GlyphId[]): number; + applyGlyphSnapshots( + requestedGlyphIds: readonly GlyphId[], + snapshots: readonly WorkspaceGlyphSnapshot[], + generation: number, + ): void; + foldAppliedChange(applied: AppliedChange): void; +} + +/** Exposes glyph snapshot freshness and dependency reads to the snapshot loader. */ +export interface GlyphSnapshotStorePort { + sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[]; + snapshotStatus(glyphId: GlyphId): GlyphSnapshotStatus; + hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean; + hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean; + loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[]; +} + /** * Renderer-local owner for font records, loaded glyph snapshots, and pending * local commits. + * + * Model reads stay on the store. Workspace mutation enters through + * {@link sync}; snapshot request coordination reads through + * {@link glyphSnapshots}. */ export class FontStore { readonly #workspace: WritableSignal; @@ -50,6 +83,29 @@ export class FontStore { #generation = 0; #pendingIntents: FontIntent[] = []; + readonly sync: FontStoreSyncPort = { + settledCell: this.#settledCell, + commitStateCell: this.#commitState, + replaceWorkspace: (snapshot) => this.#replaceWorkspace(snapshot), + enqueueIntent: (intent) => this.#enqueueIntent(intent), + hasPendingIntents: () => this.#hasPendingIntents(), + takePendingIntents: () => this.#takePendingIntents(), + beginApplying: () => this.#beginApplying(), + markSettledIfIdle: (busy) => this.#markSettledIfIdle(busy), + markSnapshotsLoading: (glyphIds) => this.#markSnapshotsLoading(glyphIds), + applyGlyphSnapshots: (requestedGlyphIds, snapshots, generation) => + this.#applyGlyphSnapshots(requestedGlyphIds, snapshots, generation), + foldAppliedChange: (applied) => this.#foldAppliedChange(applied), + }; + + readonly glyphSnapshots: GlyphSnapshotStorePort = { + sourceIdsForGlyph: (glyphId) => this.#sourceIdsForGlyph(glyphId), + snapshotStatus: (glyphId) => this.#snapshotStatusForGlyph(glyphId), + hasLayerSnapshot: (glyphId, sourceId) => this.#hasLayerSnapshot(glyphId, sourceId), + hasLayerRecord: (glyphId, sourceId) => this.#hasLayerRecord(glyphId, sourceId), + loadedComponentBaseGlyphIds: (glyphId) => this.#loadedComponentBaseGlyphIds(glyphId), + }; + constructor(workspace: WorkspaceSnapshot | null = null) { this.#workspace = signal(workspace, { name: "fontStore.workspace" }); this.#snapshotStatus = signal(EMPTY_STATUS, { name: "fontStore.snapshotStatus" }); @@ -60,23 +116,7 @@ export class FontStore { return this.#workspace; } - get snapshotStatusCell(): Signal> { - return this.#snapshotStatus; - } - - get settledCell(): Signal { - return this.#settledCell; - } - - get commitStateCell(): Signal { - return this.#commitState; - } - - get generation(): number { - return this.#generation; - } - - replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { + #replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { batch(() => { this.#generation += 1; this.#workspace.set(snapshot); @@ -93,7 +133,7 @@ export class FontStore { }); } - enqueueIntent(intent: FontIntent): void { + #enqueueIntent(intent: FontIntent): void { this.#pendingIntents.push(intent); this.#settledCell.set(false); if (this.#commitState.peek() === "idle") { @@ -101,28 +141,28 @@ export class FontStore { } } - hasPendingIntents(): boolean { + #hasPendingIntents(): boolean { return this.#pendingIntents.length > 0; } - takePendingIntents(): FontIntent[] { + #takePendingIntents(): FontIntent[] { const intents = this.#pendingIntents; this.#pendingIntents = []; return intents; } - beginApplying(): void { + #beginApplying(): void { this.#commitState.set("applying"); } - markSettledIfIdle(busy: number): void { + #markSettledIfIdle(busy: number): void { if (busy === 0 && this.#pendingIntents.length === 0) { this.#settledCell.set(true); this.#commitState.set("idle"); } } - markSnapshotsLoading(glyphIds: readonly GlyphId[]): number { + #markSnapshotsLoading(glyphIds: readonly GlyphId[]): number { const generation = this.#generation; const next = new Map(this.#snapshotStatus.peek()); for (const glyphId of glyphIds) { @@ -133,7 +173,7 @@ export class FontStore { return generation; } - applyGlyphSnapshots( + #applyGlyphSnapshots( requestedGlyphIds: readonly GlyphId[], snapshots: readonly WorkspaceGlyphSnapshot[], generation: number, @@ -169,7 +209,7 @@ export class FontStore { }); } - foldAppliedChange(applied: AppliedChange): void { + #foldAppliedChange(applied: AppliedChange): void { const current = this.#workspace.peek(); if (!current) return; @@ -242,10 +282,6 @@ export class FontStore { return this.recordForId(glyphId)?.layers.find((layer) => layer.sourceId === sourceId) ?? null; } - sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { - return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; - } - layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); return layerId ? (this.#layerStates.get(layerId) ?? null) : null; @@ -255,18 +291,6 @@ export class FontStore { return this.#variationDataByGlyph.get(glyphId) ?? null; } - 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)); - } - glyphModel(glyphId: GlyphId, create: () => Glyph | null): Glyph | null { const cached = this.#glyphModels.get(glyphId); if (cached) return cached; @@ -290,7 +314,23 @@ export class FontStore { return created; } - loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { + #sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { + return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; + } + + #snapshotStatusForGlyph(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)); + } + + #loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { const baseGlyphIds = new Set(); for (const state of this.#loadedLayerStatesForGlyph(glyphId)) { for (const baseGlyphId of componentBaseGlyphIds(state.structure)) { diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts index 6cf20b24..f50854ee 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts @@ -1,5 +1,5 @@ import type { GlyphId, SourceId } from "@shift/types"; -import type { FontStore } from "./FontStore"; +import type { GlyphSnapshotStorePort } from "./FontStore"; import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; type SnapshotRequest = { @@ -21,11 +21,11 @@ type InFlightKey = string & { readonly __inFlightKey: unique symbol }; * only dedupes requests, chooses source scopes, and follows component bases. */ export class GlyphSnapshotLoader { - readonly #store: FontStore; + readonly #store: GlyphSnapshotStorePort; readonly #edits: WorkspaceEditCoordinator; readonly #inFlight = new Set(); - constructor(store: FontStore, edits: WorkspaceEditCoordinator) { + constructor(store: GlyphSnapshotStorePort, edits: WorkspaceEditCoordinator) { this.#store = store; this.#edits = edits; } diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index f53c72d5..c12b0cc0 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -4,7 +4,7 @@ import type { WorkspaceGlyphSnapshotRequest, } from "@shared/workspace/protocol"; import type { Signal } from "@/lib/signals/signal"; -import type { FontStore, WorkspaceCommitState } from "@/lib/model/FontStore"; +import type { FontStoreSyncPort, WorkspaceCommitState } from "@/lib/model/FontStore"; import type { WorkspaceClient } from "./WorkspaceClient"; export type { WorkspaceCommitState } from "@/lib/model/FontStore"; @@ -27,13 +27,13 @@ export type { WorkspaceCommitState } from "@/lib/model/FontStore"; */ export class WorkspaceEditCoordinator { readonly #workspace: WorkspaceClient; - readonly #store: FontStore; + readonly #store: FontStoreSyncPort; #flushQueued = false; #chain: Promise = Promise.resolve(); #busy = 0; - constructor(workspace: WorkspaceClient, store: FontStore) { + constructor(workspace: WorkspaceClient, store: FontStoreSyncPort) { this.#workspace = workspace; this.#store = store; } diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index 4cd1f83f..4cf29ce3 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -45,8 +45,8 @@ export function createWorkspaceStack(): WorkspaceStack { }, }); const store = new FontStore(); - const editCoordinator = new WorkspaceEditCoordinator(client, store); - const glyphSnapshotLoader = new GlyphSnapshotLoader(store, editCoordinator); + const editCoordinator = new WorkspaceEditCoordinator(client, store.sync); + const glyphSnapshotLoader = new GlyphSnapshotLoader(store.glyphSnapshots, editCoordinator); const font = new Font(store, editCoordinator); return { @@ -64,7 +64,7 @@ export function createWorkspaceStack(): WorkspaceStack { throw new Error("workspace stack connected without a snapshot"); } - store.replaceWorkspace(snapshot); + store.sync.replaceWorkspace(snapshot); }, }; } diff --git a/apps/desktop/src/renderer/src/workspace/Workspace.ts b/apps/desktop/src/renderer/src/workspace/Workspace.ts index a3770db8..2cb205c2 100644 --- a/apps/desktop/src/renderer/src/workspace/Workspace.ts +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -35,14 +35,14 @@ export class Workspace { constructor(options: WorkspaceOptions) { this.#client = new WorkspaceClient(options.host); this.#store = new FontStore(); - this.#edits = new WorkspaceEditCoordinator(this.#client, this.#store); + this.#edits = new WorkspaceEditCoordinator(this.#client, this.#store.sync); this.#documentBridge = new WorkspaceDocumentBridge({ host: options.host, edits: this.#edits, }); this.font = new Font(this.#store, this.#edits); - this.glyphSnapshotLoader = new GlyphSnapshotLoader(this.#store, this.#edits); + this.glyphSnapshotLoader = new GlyphSnapshotLoader(this.#store.glyphSnapshots, this.#edits); this.editor = new Editor({ font: this.font, glyphSnapshotLoader: this.glyphSnapshotLoader, @@ -77,7 +77,7 @@ export class Workspace { throw new Error("workspace connected without a snapshot"); } - this.#store.replaceWorkspace(snapshot); + this.#store.sync.replaceWorkspace(snapshot); await this.#documentBridge.connect(); } catch (error) { this.#connection = null; From 79ae7c3b8c11b8867c8f739c65d622141f41b5e7 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 21:58:00 +0000 Subject: [PATCH 04/14] Hide FontStore snapshot helper --- apps/desktop/src/renderer/src/lib/model/FontStore.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index 6181367f..b1174a11 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -282,11 +282,6 @@ export class FontStore { return this.recordForId(glyphId)?.layers.find((layer) => layer.sourceId === sourceId) ?? null; } - layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { - const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); - return layerId ? (this.#layerStates.get(layerId) ?? null) : null; - } - variationData(glyphId: GlyphId): GlyphVariationData | null { return this.#variationDataByGlyph.get(glyphId) ?? null; } @@ -323,7 +318,7 @@ export class FontStore { } #hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean { - return this.layerStateForGlyphSource(glyphId, sourceId) !== null; + return this.#layerStateForGlyphSource(glyphId, sourceId) !== null; } #hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean { @@ -340,6 +335,11 @@ export class FontStore { 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( From 2f650ee9d2615b1ebfe3abd6c1ed57637f0f700a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 22:05:11 +0000 Subject: [PATCH 05/14] Guard serialized glyph snapshot freshness --- .../renderer/src/lib/model/FontStore.test.ts | 78 +++++++++++++++++++ .../src/renderer/src/lib/model/FontStore.ts | 16 +++- .../src/lib/model/GlyphSnapshotLoader.ts | 4 +- .../WorkspaceEditCoordinator.test.ts | 45 ++++++++++- .../lib/workspace/WorkspaceEditCoordinator.ts | 13 +++- 5 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/model/FontStore.test.ts 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..7f8bbf03 --- /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 generation = store.sync.markSnapshotsLoading([GLYPH_ID]); + + store.sync.replaceWorkspace(snapshot("document-b", LAYER_B_ID)); + store.sync.applyGlyphSnapshots([GLYPH_ID], [glyphSnapshot(LAYER_A_ID)], generation); + + expect(store.glyphSnapshots.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 generation = store.sync.markSnapshotsLoading([GLYPH_ID]); + + store.sync.markSnapshotsFailed([GLYPH_ID], generation); + + expect(store.glyphSnapshots.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 index b1174a11..51fb89fe 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -19,7 +19,7 @@ import { batch, signal, type Signal, type WritableSignal } from "@/lib/signals/s import { GlyphLayerState } from "./GlyphLayerState"; import type { Glyph, GlyphLayer } from "./Glyph"; -export type GlyphSnapshotStatus = "missing" | "loading" | "loaded" | "stale"; +export type GlyphSnapshotStatus = "missing" | "loading" | "loaded" | "stale" | "failed"; export type WorkspaceCommitState = "idle" | "queued" | "applying"; type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; @@ -38,6 +38,7 @@ export interface FontStoreSyncPort { beginApplying(): void; markSettledIfIdle(busy: number): void; markSnapshotsLoading(glyphIds: readonly GlyphId[]): number; + markSnapshotsFailed(glyphIds: readonly GlyphId[], generation: number): void; applyGlyphSnapshots( requestedGlyphIds: readonly GlyphId[], snapshots: readonly WorkspaceGlyphSnapshot[], @@ -93,6 +94,7 @@ export class FontStore { beginApplying: () => this.#beginApplying(), markSettledIfIdle: (busy) => this.#markSettledIfIdle(busy), markSnapshotsLoading: (glyphIds) => this.#markSnapshotsLoading(glyphIds), + markSnapshotsFailed: (glyphIds, generation) => this.#markSnapshotsFailed(glyphIds, generation), applyGlyphSnapshots: (requestedGlyphIds, snapshots, generation) => this.#applyGlyphSnapshots(requestedGlyphIds, snapshots, generation), foldAppliedChange: (applied) => this.#foldAppliedChange(applied), @@ -173,6 +175,18 @@ export class FontStore { return generation; } + #markSnapshotsFailed(glyphIds: readonly GlyphId[], generation: number): void { + if (generation !== this.#generation) return; + + const next = new Map(this.#snapshotStatus.peek()); + for (const glyphId of glyphIds) { + if (this.#snapshotGeneration.get(glyphId) === generation) { + next.set(glyphId, "failed"); + } + } + this.#snapshotStatus.set(next); + } + #applyGlyphSnapshots( requestedGlyphIds: readonly GlyphId[], snapshots: readonly WorkspaceGlyphSnapshot[], diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts index f50854ee..d5dd894c 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts @@ -89,7 +89,7 @@ export class GlyphSnapshotLoader { #neededSourceIds(glyphId: GlyphId, options: GlyphSnapshotLoadOptions): SourceId[] { const status = this.#store.snapshotStatus(glyphId); - const stale = status === "stale"; + const shouldReload = status === "stale" || status === "failed"; const needed: SourceId[] = []; const seen = new Set(); const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); @@ -99,7 +99,7 @@ export class GlyphSnapshotLoader { seen.add(sourceId); if (!this.#store.hasLayerRecord(glyphId, sourceId)) continue; if (this.#inFlight.has(inFlightKey(glyphId, sourceId))) continue; - if (stale || !this.#store.hasLayerSnapshot(glyphId, sourceId)) { + if (shouldReload || !this.#store.hasLayerSnapshot(glyphId, sourceId)) { needed.push(sourceId); } } 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 da15806a..54aa53e0 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 => ({ @@ -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, glyphSnapshotLoader, store, workspaceSync } = stack; + const glyphId = mintGlyphId(); + const layerId = mintLayerId(); + await workspaceSync.apply([ + { + kind: "createGlyph", + createGlyph: { glyphId, name: "D" as GlyphName, unicodes: [68 as Unicode] }, + }, + { + kind: "createGlyphLayer", + createGlyphLayer: { layerId, glyphId, sourceId: font.defaultSource.id }, + }, + ]); + await glyphSnapshotLoader.load([glyphId]); + + const axisId = mintAxisId(); + workspaceSync.push({ + kind: "createAxis", + createAxis: { + axisId, + tag: "wght", + name: "Weight", + min: 100, + default: 400, + max: 900, + hidden: false, + }, + }); + + await glyphSnapshotLoader.load([glyphId]); + + expect(font.getAxes().map((axis) => axis.id)).toEqual([axisId]); + expect(store.glyphSnapshots.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 c12b0cc0..f7e488fa 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -98,9 +98,16 @@ export class WorkspaceEditCoordinator { if (requests.length === 0) return; const glyphIds = requests.map((request) => request.glyphId); - const generation = this.#store.markSnapshotsLoading(glyphIds); - const snapshots = await this.#withFlush(() => this.#workspace.glyphSnapshots(requests)); - this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); + await this.#withFlush(async () => { + const generation = this.#store.markSnapshotsLoading(glyphIds); + try { + const snapshots = await this.#workspace.glyphSnapshots(requests); + this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); + } catch (error) { + this.#store.markSnapshotsFailed(glyphIds, generation); + throw error; + } + }); } /** Replays the latest redo entry after pending pushes flush. */ From 8aaa4cfba35144f282c5bc18beac1d0cc789eec8 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 22:12:01 +0000 Subject: [PATCH 06/14] Request snapshots for previews and text --- .../src/components/home/GlyphPreview.tsx | 14 +++++++++++- .../src/renderer/src/lib/editor/Editor.ts | 22 ++++++++++++++++++- .../src/renderer/src/lib/tools/text/Text.ts | 7 ++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 3dcda77e..7330903e 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; import type { Glyph } from "@/lib/model/Glyph"; @@ -51,7 +52,18 @@ interface GlyphPreviewProps { } export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPreviewProps) { - if (!font.loaded) { + const editor = getEditor(); + const fontLoaded = useSignalState(font.$loaded); + const record = fontLoaded ? font.recordForName(handle.name) : null; + const recordId = record?.id ?? null; + const defaultSourceId = record ? font.defaultSource.id : null; + + useEffect(() => { + if (!recordId || !defaultSourceId) return; + editor.requestGlyphSnapshots([recordId], { sourceIds: [defaultSourceId] }); + }, [defaultSourceId, editor, recordId]); + + if (!fontLoaded) { return ; } diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index db2087c9..0e8dd12e 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -75,7 +75,10 @@ import { TextRuns } from "@/lib/text/TextRuns"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; import { glyphTextItem, Positioner } from "@/lib/text/layout"; import type { GlyphAnchor } from "@/lib/text/layout"; -import type { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; +import type { + GlyphSnapshotLoader, + GlyphSnapshotLoadOptions, +} from "@/lib/model/GlyphSnapshotLoader"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; @@ -626,6 +629,19 @@ export class Editor { return { ready, close }; } + /** + * Requests local glyph geometry without making model getters asynchronous. + * + * @param glyphIds - stable glyph identities whose snapshots should be hydrated. + * @param options - optional exact source scope for the snapshot request. + */ + public requestGlyphSnapshots( + glyphIds: readonly GlyphId[], + options: GlyphSnapshotLoadOptions = {}, + ): void { + this.#glyphSnapshotLoader?.request(glyphIds, options); + } + /** * Creates an empty glyph in the loaded font. * @@ -829,6 +845,10 @@ 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.requestGlyphSnapshots([record.id], { sourceIds: [this.font.defaultSource.id] }); + } this.textRun.insert(glyphTextItem(handle.name, codepoint)); } 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..0f9e377f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -32,6 +32,13 @@ export class TextTool extends BaseTool { } const ownerName = owner.name; + const record = this.editor.font.recordForName(ownerName); + if (record) { + this.editor.requestGlyphSnapshots([record.id], { + sourceIds: [this.editor.font.defaultSource.id], + }); + } + const run = this.editor.textRuns.switchTo(ownerName); run.seed(glyphTextItem(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); run.interaction.suspend(); From c95228199a7d81342ef3d9ef341acb67b4a0361e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 22:19:24 +0000 Subject: [PATCH 07/14] Narrow glyph snapshot loader boundary --- .../src/lib/model/GlyphSnapshotLoader.test.ts | 142 ++++++++++++++++++ .../src/lib/model/GlyphSnapshotLoader.ts | 14 +- 2 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts new file mode 100644 index 00000000..4488bdcf --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { + ComponentId, + GlyphId, + GlyphName, + GlyphState, + LayerId, + SourceId, + Unicode, +} from "@shift/types"; +import type { + WorkspaceGlyphSnapshot, + WorkspaceGlyphSnapshotRequest, + WorkspaceSnapshot, +} from "@shared/workspace/protocol"; +import { FontStore, type FontStoreSyncPort } from "./FontStore"; +import { GlyphSnapshotLoader, type GlyphSnapshotLoadPort } from "./GlyphSnapshotLoader"; + +const SOURCE_ID = "source_regular" as SourceId; +const GLYPH_A_ID = "glyph_a" as GlyphId; +const GLYPH_B_ID = "glyph_b" as GlyphId; +const LAYER_A_ID = "layer_a" as LayerId; +const LAYER_B_ID = "layer_b" as LayerId; + +describe("glyph snapshot loading follows component dependencies", () => { + it("loads component base glyph snapshots discovered from loaded geometry", async () => { + const store = new FontStore(workspaceSnapshot()); + const sync = new SnapshotFixtureSync(store.sync, [glyphSnapshotA(), glyphSnapshotB()]); + const loader = new GlyphSnapshotLoader(store.glyphSnapshots, sync); + + await loader.load([GLYPH_A_ID]); + + expect(store.glyphSnapshots.snapshotStatus(GLYPH_A_ID)).toBe("loaded"); + expect(store.glyphSnapshots.snapshotStatus(GLYPH_B_ID)).toBe("loaded"); + expect(store.layerState(LAYER_A_ID)?.geometry.components[0]?.baseGlyphId).toBe(GLYPH_B_ID); + expect(store.layerState(LAYER_B_ID)?.xAdvance).toBe(500); + }); +}); + +class SnapshotFixtureSync implements GlyphSnapshotLoadPort { + readonly #store: FontStoreSyncPort; + readonly #snapshots: ReadonlyMap; + + constructor(store: FontStoreSyncPort, snapshots: readonly WorkspaceGlyphSnapshot[]) { + this.#store = store; + this.#snapshots = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); + } + + async loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { + const glyphIds = requests.map((request) => request.glyphId); + const generation = this.#store.markSnapshotsLoading(glyphIds); + const snapshots = glyphIds + .map((glyphId) => this.#snapshots.get(glyphId)) + .filter((snapshot): snapshot is WorkspaceGlyphSnapshot => Boolean(snapshot)); + + this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); + } +} + +function workspaceSnapshot(): WorkspaceSnapshot { + return { + documentId: "document", + metadata: { familyName: "Untitled Font" }, + metrics: { unitsPerEm: 1000, ascender: 800, descender: -200 }, + glyphs: [ + { + id: GLYPH_A_ID, + name: "A" as GlyphName, + unicodes: [65 as Unicode], + componentBaseGlyphIds: [GLYPH_B_ID], + layers: [{ id: LAYER_A_ID, sourceId: SOURCE_ID }], + }, + { + id: GLYPH_B_ID, + name: "B" as GlyphName, + unicodes: [66 as Unicode], + componentBaseGlyphIds: [], + layers: [{ id: LAYER_B_ID, sourceId: SOURCE_ID }], + }, + ], + sources: [ + { + id: SOURCE_ID, + name: "Regular", + location: { values: {} }, + }, + ], + axes: [], + }; +} + +function glyphSnapshotA(): WorkspaceGlyphSnapshot { + return { + glyphId: GLYPH_A_ID, + layers: [ + { + glyphId: GLYPH_A_ID, + sourceId: SOURCE_ID, + state: componentGlyphState(), + }, + ], + }; +} + +function glyphSnapshotB(): WorkspaceGlyphSnapshot { + return { + glyphId: GLYPH_B_ID, + layers: [ + { + glyphId: GLYPH_B_ID, + sourceId: SOURCE_ID, + state: simpleGlyphState(LAYER_B_ID), + }, + ], + }; +} + +function componentGlyphState(): GlyphState { + return { + layerId: LAYER_A_ID, + structure: { + contours: [], + anchors: [], + components: [ + { + id: "component_b" as ComponentId, + baseGlyphId: GLYPH_B_ID, + baseGlyphName: "B" as GlyphName, + }, + ], + }, + values: new Float64Array([600, 0, 0, 0, 1, 1, 0, 0, 0, 0]), + }; +} + +function simpleGlyphState(layerId: LayerId): GlyphState { + return { + layerId, + structure: { contours: [], anchors: [], components: [] }, + values: new Float64Array([500]), + }; +} diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts index d5dd894c..ead2d596 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts @@ -1,6 +1,6 @@ import type { GlyphId, SourceId } from "@shift/types"; +import type { WorkspaceGlyphSnapshotRequest } from "@shared/workspace/protocol"; import type { GlyphSnapshotStorePort } from "./FontStore"; -import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; type SnapshotRequest = { glyphId: GlyphId; @@ -11,6 +11,10 @@ export type GlyphSnapshotLoadOptions = { readonly sourceIds?: readonly SourceId[]; }; +export interface GlyphSnapshotLoadPort { + loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise; +} + type InFlightKey = string & { readonly __inFlightKey: unique symbol }; /** @@ -22,12 +26,12 @@ type InFlightKey = string & { readonly __inFlightKey: unique symbol }; */ export class GlyphSnapshotLoader { readonly #store: GlyphSnapshotStorePort; - readonly #edits: WorkspaceEditCoordinator; + readonly #sync: GlyphSnapshotLoadPort; readonly #inFlight = new Set(); - constructor(store: GlyphSnapshotStorePort, edits: WorkspaceEditCoordinator) { + constructor(store: GlyphSnapshotStorePort, sync: GlyphSnapshotLoadPort) { this.#store = store; - this.#edits = edits; + this.#sync = sync; } /** @@ -45,7 +49,7 @@ export class GlyphSnapshotLoader { for (const request of batch) this.#markInFlight(request); try { - await this.#edits.loadGlyphSnapshots(batch); + await this.#sync.loadGlyphSnapshots(batch); } finally { for (const request of batch) this.#unmarkInFlight(request); } From 768664e4b48fafc87ed4bb691061d465adabe864 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 22:27:22 +0000 Subject: [PATCH 08/14] Settle before glyph snapshot cache reads --- .../src/renderer/src/lib/model/FontStore.ts | 22 ++++--- .../src/lib/model/GlyphSnapshotLoader.test.ts | 58 ++++++++++++++++--- .../src/lib/model/GlyphSnapshotLoader.ts | 10 +++- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index 51fb89fe..b7d54630 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -269,14 +269,7 @@ export class FontStore { } } - if (staleGlyphIds.size > 0) { - const nextStatus = new Map(this.#snapshotStatus.peek()); - for (const glyphId of staleGlyphIds) { - const status = nextStatus.get(glyphId); - if (status === "loaded") nextStatus.set(glyphId, "stale"); - } - this.#snapshotStatus.set(nextStatus); - } + this.#markLoadedSnapshotsStale(staleGlyphIds); }); } @@ -385,6 +378,19 @@ export class FontStore { .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(); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts index 4488bdcf..8fef7dcb 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import type { + Axis, + AxisId, ComponentId, GlyphId, GlyphName, @@ -14,9 +16,10 @@ import type { WorkspaceSnapshot, } from "@shared/workspace/protocol"; import { FontStore, type FontStoreSyncPort } from "./FontStore"; -import { GlyphSnapshotLoader, type GlyphSnapshotLoadPort } from "./GlyphSnapshotLoader"; +import { GlyphSnapshotLoader, type GlyphSnapshotSyncPort } from "./GlyphSnapshotLoader"; const SOURCE_ID = "source_regular" as SourceId; +const AXIS_ID = "axis_weight" as AxisId; const GLYPH_A_ID = "glyph_a" as GlyphId; const GLYPH_B_ID = "glyph_b" as GlyphId; const LAYER_A_ID = "layer_a" as LayerId; @@ -35,15 +38,44 @@ describe("glyph snapshot loading follows component dependencies", () => { expect(store.layerState(LAYER_A_ID)?.geometry.components[0]?.baseGlyphId).toBe(GLYPH_B_ID); expect(store.layerState(LAYER_B_ID)?.xAdvance).toBe(500); }); + + it("settles queued workspace edits before checking snapshot freshness", async () => { + const store = new FontStore(workspaceSnapshot()); + const generation = store.sync.markSnapshotsLoading([GLYPH_A_ID]); + store.sync.applyGlyphSnapshots([GLYPH_A_ID], [glyphSnapshotA(600)], generation); + const sync = new SnapshotFixtureSync( + store.sync, + [glyphSnapshotA(700), glyphSnapshotB()], + () => { + store.sync.foldAppliedChange({ layers: [], axes: [weightAxis()], dependents: [] }); + }, + ); + const loader = new GlyphSnapshotLoader(store.glyphSnapshots, sync); + + await loader.load([GLYPH_A_ID]); + + expect(store.glyphSnapshots.snapshotStatus(GLYPH_A_ID)).toBe("loaded"); + expect(store.layerState(LAYER_A_ID)?.xAdvance).toBe(700); + }); }); -class SnapshotFixtureSync implements GlyphSnapshotLoadPort { +class SnapshotFixtureSync implements GlyphSnapshotSyncPort { readonly #store: FontStoreSyncPort; readonly #snapshots: ReadonlyMap; + readonly #settle: () => void; - constructor(store: FontStoreSyncPort, snapshots: readonly WorkspaceGlyphSnapshot[]) { + constructor( + store: FontStoreSyncPort, + snapshots: readonly WorkspaceGlyphSnapshot[], + settle: () => void = () => {}, + ) { this.#store = store; this.#snapshots = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); + this.#settle = settle; + } + + async settled(): Promise { + this.#settle(); } async loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { @@ -89,14 +121,14 @@ function workspaceSnapshot(): WorkspaceSnapshot { }; } -function glyphSnapshotA(): WorkspaceGlyphSnapshot { +function glyphSnapshotA(xAdvance = 600): WorkspaceGlyphSnapshot { return { glyphId: GLYPH_A_ID, layers: [ { glyphId: GLYPH_A_ID, sourceId: SOURCE_ID, - state: componentGlyphState(), + state: componentGlyphState(xAdvance), }, ], }; @@ -115,7 +147,7 @@ function glyphSnapshotB(): WorkspaceGlyphSnapshot { }; } -function componentGlyphState(): GlyphState { +function componentGlyphState(xAdvance: number): GlyphState { return { layerId: LAYER_A_ID, structure: { @@ -129,7 +161,7 @@ function componentGlyphState(): GlyphState { }, ], }, - values: new Float64Array([600, 0, 0, 0, 1, 1, 0, 0, 0, 0]), + values: new Float64Array([xAdvance, 0, 0, 0, 1, 1, 0, 0, 0, 0]), }; } @@ -140,3 +172,15 @@ function simpleGlyphState(layerId: LayerId): GlyphState { values: new Float64Array([500]), }; } + +function weightAxis(): Axis { + return { + id: AXIS_ID, + tag: "wght", + name: "Weight", + min: 100, + default: 400, + max: 900, + hidden: false, + }; +} diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts index ead2d596..901e329e 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts @@ -11,7 +11,8 @@ export type GlyphSnapshotLoadOptions = { readonly sourceIds?: readonly SourceId[]; }; -export interface GlyphSnapshotLoadPort { +export interface GlyphSnapshotSyncPort { + settled(): Promise; loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise; } @@ -26,10 +27,10 @@ type InFlightKey = string & { readonly __inFlightKey: unique symbol }; */ export class GlyphSnapshotLoader { readonly #store: GlyphSnapshotStorePort; - readonly #sync: GlyphSnapshotLoadPort; + readonly #sync: GlyphSnapshotSyncPort; readonly #inFlight = new Set(); - constructor(store: GlyphSnapshotStorePort, sync: GlyphSnapshotLoadPort) { + constructor(store: GlyphSnapshotStorePort, sync: GlyphSnapshotSyncPort) { this.#store = store; this.#sync = sync; } @@ -41,6 +42,9 @@ export class GlyphSnapshotLoader { * @param options - optional source scope; omitted means every authored layer for each glyph. */ async load(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions = {}): Promise { + if (glyphIds.length === 0) return; + await this.#sync.settled(); + const queue = this.#requestable(glyphIds, options); const seen = new Set(queue.map((request) => request.glyphId)); From a9fb102c430c27259ec4a16535c1194eccd474e0 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 21 Jun 2026 22:55:08 +0000 Subject: [PATCH 09/14] Tighten glyph model identity reads --- .../src/components/home/GlyphPreview.tsx | 9 +++-- .../src/renderer/src/lib/editor/Editor.ts | 15 ++++---- .../renderer/src/lib/editor/rendering/Text.ts | 2 +- .../src/renderer/src/lib/model/Font.ts | 36 ------------------- .../src/renderer/src/lib/model/Glyph.test.ts | 6 ++-- .../src/renderer/src/lib/model/Glyph.ts | 2 +- .../src/lib/text/layout/Positioner.test.ts | 10 +++--- .../src/lib/text/layout/Positioner.ts | 19 ++++++---- .../src/lib/text/layout/TextLayout.test.ts | 22 ++++++------ .../renderer/src/lib/text/layout/testUtils.ts | 8 ++--- .../src/renderer/src/lib/text/layout/types.ts | 4 ++- .../src/renderer/src/lib/tools/text/Text.ts | 6 +--- 12 files changed, 54 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 7330903e..68eacbff 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -56,18 +56,17 @@ export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPrevie const fontLoaded = useSignalState(font.$loaded); const record = fontLoaded ? font.recordForName(handle.name) : null; const recordId = record?.id ?? null; - const defaultSourceId = record ? font.defaultSource.id : null; useEffect(() => { - if (!recordId || !defaultSourceId) return; - editor.requestGlyphSnapshots([recordId], { sourceIds: [defaultSourceId] }); - }, [defaultSourceId, editor, recordId]); + if (!recordId) return; + editor.requestGlyphSnapshots([recordId]); + }, [editor, recordId]); if (!fontLoaded) { return ; } - const glyph = font.glyph(handle); + const glyph = recordId ? font.glyphForId(recordId) : null; if (!glyph) { return ; } diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 0e8dd12e..5b3b92d5 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -530,15 +530,16 @@ export class Editor { /** * 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. + * This is a read/focus API. It accepts route/text handles at the edge, + * resolves committed glyph identity, 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); + const record = this.font.recordForName(handle.name); + const glyph = record ? this.font.glyphForId(record.id) : null; if (!glyph) return null; this.#glyph.open.glyph.set(glyph); @@ -846,9 +847,7 @@ export class Editor { const handle = this.font.glyphHandleForUnicode(codepoint); if (!handle) return; const record = this.font.recordForName(handle.name); - if (record) { - this.requestGlyphSnapshots([record.id], { sourceIds: [this.font.defaultSource.id] }); - } + if (record) this.requestGlyphSnapshots([record.id]); this.textRun.insert(glyphTextItem(handle.name, codepoint)); } 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.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index a06c9245..462c463c 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -602,22 +602,6 @@ export class Font { return { name, unicode }; } - /** - * Returns the local glyph model for a name-based handle. - * - * @remarks - * This is a pure local read. It does not request geometry; callers that need - * missing snapshots should use the glyph snapshot loader before expecting a - * model. - * - * @param handle - glyph identity from a text, catalog, or finder flow. - * @returns the glyph model, or null when the glyph is missing or not loaded. - */ - glyph(handle: GlyphHandle): Glyph | null { - const record = this.#directory.peek().recordForName(handle.name); - return record ? this.glyphForId(record.id) : null; - } - /** * Returns the local glyph model for a stable glyph id. * @@ -643,26 +627,6 @@ export class Font { }); } - /** - * Get authored layer data for a glyph at an exact source. - * - * 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. - * - * @example - * ```ts - * const source = font.defaultSource - * const glyphLayer = font.glyphLayer(handle, source) - * ``` - * - * @returns The authored glyph layer, or `null` when the source or layer state is unavailable. - */ - glyphLayer(handle: GlyphHandle, source: Source): GlyphLayer | null { - const record = this.#directory.peek().recordForName(handle.name); - return record ? this.glyphLayerForId(record.id, source.id) : null; - } - /** * Returns local authored layer data for an exact glyph/source pair. * 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..f7eae342 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -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(editor.rootGlyphHandle!.name)!; + 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(editor.rootGlyphHandle!.name)!; + 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 55323eff..379cc9df 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -1520,7 +1520,7 @@ export class Glyph { return source.id === this.#source.id; } - /** @internal GlyphLayer caching is owned by Font.glyphLayer(). */ + /** @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); 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 b56991ae..6b622e38 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"; @@ -47,11 +47,11 @@ export async function layoutTestFont(): Promise { await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId, width: advance } }, ]); - await stack.glyphSnapshotLoader.load([record.id], { sourceIds: [stack.font.defaultSource.id] }); + await stack.glyphSnapshotLoader.load([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 0f9e377f..42e5a591 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -33,11 +33,7 @@ export class TextTool extends BaseTool { const ownerName = owner.name; const record = this.editor.font.recordForName(ownerName); - if (record) { - this.editor.requestGlyphSnapshots([record.id], { - sourceIds: [this.editor.font.defaultSource.id], - }); - } + if (record) this.editor.requestGlyphSnapshots([record.id]); const run = this.editor.textRuns.switchTo(ownerName); run.seed(glyphTextItem(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); From ed4dedbf960770c13e977994f1117305e1ede7a6 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 22 Jun 2026 08:00:07 +0000 Subject: [PATCH 10/14] Refactor glyph loading around font scene ownership --- apps/desktop/src/renderer/src/app/Screens.tsx | 3 +- .../renderer/src/components/editor/Canvas.tsx | 17 +- .../src/components/home/GlyphGrid.tsx | 5 +- .../src/components/home/GlyphPreview.tsx | 5 +- .../renderer/src/lib/editor/Editor.test.ts | 70 ++--- .../src/renderer/src/lib/editor/Editor.ts | 244 +++--------------- .../renderer/src/lib/editor/EditorState.ts | 115 ++++----- .../src/renderer/src/lib/editor/Scene.ts | 41 +-- .../src/renderer/src/lib/model/Font.test.ts | 16 +- .../src/renderer/src/lib/model/Font.ts | 177 ++++++++++++- .../renderer/src/lib/model/FontStore.test.ts | 14 +- .../src/renderer/src/lib/model/FontStore.ts | 155 +++-------- .../src/renderer/src/lib/model/Glyph.test.ts | 6 +- .../src/lib/model/GlyphSnapshotLoader.test.ts | 186 ------------- .../src/lib/model/GlyphSnapshotLoader.ts | 133 ---------- .../renderer/src/lib/model/variation.test.ts | 4 +- .../renderer/src/lib/text/layout/testUtils.ts | 2 +- .../src/renderer/src/lib/tools/text/Text.ts | 10 +- .../WorkspaceEditCoordinator.test.ts | 12 +- .../lib/workspace/WorkspaceEditCoordinator.ts | 67 ++--- .../renderer/src/perf/workspaceApply.bench.ts | 2 +- .../src/renderer/src/testing/TestEditor.ts | 14 +- .../renderer/src/testing/workspaceStack.ts | 8 +- .../src/renderer/src/workspace/Workspace.ts | 13 +- 24 files changed, 418 insertions(+), 901 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts 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 2f40f717..812f392b 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -23,8 +23,21 @@ export const Canvas: FC = () => { const fontLoaded = useSignalState(editor.font.$loaded); useEffect(() => { - const route = editor.openGlyphRoute(glyphIdParam ? asGlyphId(glyphIdParam) : null); - return () => route.close(); + if (!fontLoaded || !glyphIdParam) { + editor.scene.clear(); + return undefined; + } + + const glyphId = asGlyphId(glyphIdParam); + if (!editor.font.recordForId(glyphId)) { + editor.scene.clear(); + return undefined; + } + + editor.scene.clear(); + const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); + editor.scene.setGeometryItems([itemId]); + return () => editor.scene.clear(); }, [editor, fontLoaded, glyphIdParam]); useEffect(() => { diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 8ad9fdf1..5e5aed5f 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -106,11 +106,12 @@ export const GlyphGrid = memo(function GlyphGrid() { }); const handleCellClick = useCallback( - (glyph: GlyphCatalogItem) => { + async (glyph: GlyphCatalogItem) => { if (!glyph.id) return; + await editor.font.ensureGlyphs([glyph.id]); navigate(`/editor/${encodeURIComponent(glyph.id)}`); }, - [navigate], + [editor, navigate], ); return ( diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 68eacbff..d7a8745b 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -52,15 +52,14 @@ interface GlyphPreviewProps { } export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPreviewProps) { - const editor = getEditor(); const fontLoaded = useSignalState(font.$loaded); const record = fontLoaded ? font.recordForName(handle.name) : null; const recordId = record?.id ?? null; useEffect(() => { if (!recordId) return; - editor.requestGlyphSnapshots([recordId]); - }, [editor, recordId]); + font.requestGlyphs([recordId]); + }, [font, recordId]); if (!fontLoaded) { return ; 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 059d516b..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,11 +28,12 @@ describe("Editor", () => { expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); }); - it("opens route glyphs through the editor route session", async () => { + it("derives active glyph from the geometry-shown scene item", () => { const record = editor.font.recordForName("S")!; - const session = editor.openGlyphRoute(record.id); - const itemId = editor.scene.value.items[0]?.id ?? null; + 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({ @@ -40,22 +41,22 @@ describe("Editor", () => { glyphId: record.id, placement: { origin: { x: 0, y: 0 } }, }); - expect(itemId && editor.scene.isGeometryShown(itemId)).toBe(true); - - const glyph = await session.ready; - - expect(glyph?.id).toBe(record.id); - expect(editor.glyph.peek()).toBe(glyph); - expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); + expect(editor.scene.isGeometryShown(itemId)).toBe(true); + expect(editor.glyph.peek()?.id).toBe(record.id); + expect(editor.layerForItem(itemId)).not.toBeNull(); }); - it("cancels stale route sessions before they focus", async () => { + it("clearing the scene clears the derived active glyph", () => { const record = editor.font.recordForName("S")!; - const session = editor.openGlyphRoute(record.id); - session.close(); + 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(await session.ready).toBeNull(); expect(editor.scene.value.items).toEqual([]); expect(editor.glyph.peek()).toBeNull(); }); @@ -132,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 5b3b92d5..e749f68c 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"; @@ -75,10 +77,6 @@ import { TextRuns } from "@/lib/text/TextRuns"; import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; import { glyphTextItem, Positioner } from "@/lib/text/layout"; import type { GlyphAnchor } from "@/lib/text/layout"; -import type { - GlyphSnapshotLoader, - GlyphSnapshotLoadOptions, -} from "@/lib/model/GlyphSnapshotLoader"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; @@ -101,25 +99,9 @@ import { interface EditorOptions { font: Font; - glyphSnapshotLoader?: GlyphSnapshotLoader; clipboard: SystemClipboard; } -/** - * Represents one route-owned glyph opening lifecycle. - * - * @remarks - * Closing the session cancels stale async focus work and clears the editor only - * while this session is still the active route. - */ -export interface GlyphRouteSession { - /** Resolves with the focused glyph, or null when the route is missing or cancelled. */ - readonly ready: Promise; - - /** Cancels this route session if it is still current. */ - close(): void; -} - /** * Central orchestrator for the glyph editing surface. * @@ -164,7 +146,6 @@ export class Editor { readonly hover: Hover; readonly font: Font; readonly scene: Scene; - readonly #glyphSnapshotLoader: GlyphSnapshotLoader | null; /** * Rendering and camera infrastructure. @@ -207,6 +188,7 @@ export class Editor { * queries. */ #glyph: EditorGlyphState; + #designLocation: WritableSignal; #cursorEffect: Effect; #cameraMetricsEffect: Effect; @@ -218,7 +200,6 @@ export class Editor { #textRuns: TextRuns; #glyphFinderOpen: WritableSignal; - #glyphRouteGeneration = 0; #zone: FocusZone = "canvas"; @@ -247,9 +228,9 @@ export class Editor { this.#camera = new Camera(); this.font = options.font; - this.#glyphSnapshotLoader = options.glyphSnapshotLoader ?? null; 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(); @@ -288,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); @@ -527,122 +508,6 @@ export class Editor { this.#renderer.clearMarkerCanvas(); } - /** - * Focus an existing glyph model in the editor. - * - * This is a read/focus API. It accepts route/text handles at the edge, - * resolves committed glyph identity, 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 record = this.font.recordForName(handle.name); - const glyph = record ? this.font.glyphForId(record.id) : null; - 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); - const glyph = this.font.glyphForId(glyphId); - if (!glyph) { - this.#glyph.open.glyph.set(null); - this.#glyph.open.rootHandle.set(handle); - return null; - } - - this.#glyph.open.rootHandle.set(handle); - this.scene.setLocation(location); - this.#glyph.layerEditing.followDesignLocation(); - this.#glyph.open.glyph.set(glyph); - return glyph; - } - - /** - * Opens a route-selected glyph in the scene and focuses it after geometry loads. - * - * @remarks - * The scene item is placed synchronously so guides and route state update - * immediately. Geometry hydration runs through the glyph snapshot loader; if a - * later route opens before it resolves, the stale completion is ignored. - * - * @param glyphId - document glyph identity from the editor route, or null to clear. - * @returns route session handle for cancellation and optional readiness awaits. - */ - public openGlyphRoute(glyphId: GlyphId | null): GlyphRouteSession { - const generation = ++this.#glyphRouteGeneration; - const close = () => this.#closeGlyphRoute(generation); - - if (!glyphId) { - this.close(); - return { ready: Promise.resolve(null), close }; - } - - const record = this.font.recordForId(glyphId); - if (!record) { - this.close(); - return { ready: Promise.resolve(null), close }; - } - - const location = this.font.defaultLocation(); - const handle = this.font.glyphHandleForName(record.name); - batch(() => { - this.scene.clear(); - this.scene.setLocation(location); - const itemId = this.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - this.scene.setGeometryItems([itemId]); - this.#glyph.open.rootHandle.set(handle); - this.#glyph.open.glyph.set(null); - this.#glyph.layerEditing.followDesignLocation(); - }); - - const ready = this.#loadRouteGlyph(glyphId, location, generation).catch((error) => { - if (this.#glyphRouteGeneration === generation) { - console.error("failed to open glyph route", error); - } - return null; - }); - return { ready, close }; - } - - /** - * Requests local glyph geometry without making model getters asynchronous. - * - * @param glyphIds - stable glyph identities whose snapshots should be hydrated. - * @param options - optional exact source scope for the snapshot request. - */ - public requestGlyphSnapshots( - glyphIds: readonly GlyphId[], - options: GlyphSnapshotLoadOptions = {}, - ): void { - this.#glyphSnapshotLoader?.request(glyphIds, options); - } - /** * Creates an empty glyph in the loaded font. * @@ -674,7 +539,8 @@ 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.requestGlyphs([record.id]); this.disableProofMode(); }); } @@ -698,48 +564,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); - } - - async #loadRouteGlyph( - glyphId: GlyphId, - location: AxisLocation, - generation: number, - ): Promise { - await this.#glyphSnapshotLoader?.load([glyphId]); - if (this.#glyphRouteGeneration !== generation) return null; - return this.focusGlyph(glyphId, location); - } - - #closeGlyphRoute(generation: number): void { - if (this.#glyphRouteGeneration !== generation) return; - this.#glyphRouteGeneration += 1; - this.close(); + 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. */ @@ -767,23 +615,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)); } /** @@ -809,28 +644,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 { 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(); + this.setDesignLocation(this.font.defaultLocation()); } public get textRuns(): TextRuns { @@ -847,7 +675,7 @@ export class Editor { const handle = this.font.glyphHandleForUnicode(codepoint); if (!handle) return; const record = this.font.recordForName(handle.name); - if (record) this.requestGlyphSnapshots([record.id]); + if (record) this.font.requestGlyphs([record.id]); this.textRun.insert(glyphTextItem(handle.name, codepoint)); } @@ -874,7 +702,7 @@ export class Editor { } public get glyph(): Signal { - return this.#glyph.open.glyph; + return this.#glyph.active.glyph; } public get glyphInstanceCell(): Signal { @@ -892,13 +720,13 @@ export class Editor { 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; return this.font.glyphLayerForId(item.glyphId, source.id); } @@ -1178,7 +1006,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 }); @@ -1188,7 +1016,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, { @@ -1319,22 +1147,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 { @@ -1345,7 +1173,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 8df396e4..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,30 +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 layerId = font.layerRecordForId(glyph.id, source.id)?.id ?? null; + const layerId = font.layerRecordForId(activeGlyph.id, source.id)?.id ?? null; return { sourceId: source.id, layerId }; }, { name: "editor.glyph.layerEditing.layer" }, @@ -267,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.glyphLayerForId(glyph.id, source.id); + 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); - } } /** @@ -312,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" }, ); @@ -331,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/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index 14899f8c..10a0f378 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -52,7 +52,7 @@ describe("Font projects the workspace snapshot", () => { const store = new FontStore(); const font = new Font(store); - store.sync.replaceWorkspace(SNAPSHOT); + store.replaceWorkspace(SNAPSHOT); expect(font.loaded).toBe(true); expect(font.metrics.unitsPerEm).toBe(2048); @@ -70,7 +70,7 @@ describe("Font projects the workspace snapshot", () => { expect(font.$loaded.value).toBe(false); - store.sync.replaceWorkspace(SNAPSHOT); + store.replaceWorkspace(SNAPSHOT); expect(font.$loaded.value).toBe(true); }); @@ -81,7 +81,7 @@ describe("Font projects the workspace snapshot", () => { expect(font.loaded).toBe(true); - store.sync.replaceWorkspace(null); + store.replaceWorkspace(null); expect(font.loaded).toBe(false); expect(font.metrics.unitsPerEm).toBe(1000); @@ -192,7 +192,7 @@ describe("font-level intents make the font variable", () => { expect(committed?.layers).toEqual(record.layers); expect(stack.font.layerRecordForId(record.id, source.id)).toEqual(record.layers[0]); - await stack.glyphSnapshotLoader.load([record.id], { sourceIds: [source.id] }); + await stack.font.ensureGlyphs([record.id], { sourceIds: [source.id] }); const glyph = stack.font.glyphForId(record.id); expect(glyph?.xAdvance).toBe(stack.font.defaultXAdvance); }); @@ -203,7 +203,7 @@ describe("font-level intents make the font variable", () => { const record = stack.font.createGlyph("A" as GlyphName); await stack.editCoordinator.settled(); - await stack.glyphSnapshotLoader.load([record.id]); + await stack.font.ensureGlyphs([record.id]); const glyph = stack.font.glyphForId(record.id); if (!glyph) throw new Error("Expected loaded glyph"); @@ -272,7 +272,7 @@ describe("font-level intents make the font variable", () => { }, ]); - await stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [stack.font.defaultSource.id] }); + await stack.font.ensureGlyphs([glyphId], { sourceIds: [stack.font.defaultSource.id] }); const glyph = stack.font.glyphForId(glyphId); if (!glyph) throw new Error("Expected default glyph layer to open"); expect(glyph.xAdvance).toBe(640); @@ -340,8 +340,8 @@ describe("font-level intents make the font variable", () => { const boldSource = stack.font.source(boldSourceId); if (!regularSource || !boldSource) throw new Error("Expected both sources"); - const regularLoad = stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [defaultSourceId] }); - const boldLoad = stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [boldSourceId] }); + const regularLoad = stack.font.ensureGlyphs([glyphId], { sourceIds: [defaultSourceId] }); + const boldLoad = stack.font.ensureGlyphs([glyphId], { sourceIds: [boldSourceId] }); await Promise.all([regularLoad, boldLoad]); expect(stack.font.glyphLayerForId(glyphId, regularSource.id)?.id).toBe(defaultLayerId); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 462c463c..65786955 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -17,9 +17,10 @@ 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 { WorkspaceGlyphSnapshotRequest } from "@shared/workspace/protocol"; import { Glyph, type GlyphLayer } from "./Glyph"; import { GlyphOutline } from "./GlyphOutline"; -import type { FontStore } from "./FontStore"; +import type { FontStore, GlyphSnapshotStatus } from "./FontStore"; import type { GlyphLayerState } from "./GlyphLayerState"; import type { GlyphHandle } from "@shift/bridge"; import { @@ -33,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. * @@ -297,12 +309,13 @@ export class Font { readonly #directory: Signal; readonly #store: FontStore; readonly #editCoordinator: WorkspaceEditCoordinator | null; + readonly #glyphLoadsInFlight = new Map>(); /** * Projects the renderer's workspace snapshot into the font domain model. * * @param store - Renderer-local owner of committed records and loaded glyph snapshots. - * @param editCoordinator - Optional queue used by authored layer projections to submit + * @param editCoordinator - Optional sync lane used by authored layer projections to submit * committed edits to the utility workspace. */ constructor(store: FontStore, editCoordinator?: WorkspaceEditCoordinator) { @@ -369,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(); @@ -650,6 +668,153 @@ export class Font { }); } + /** + * Reports whether every authored source for each glyph has local geometry. + * + * @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 missing or stale glyph geometry and discovered component bases. + * + * @param glyphIds - Stable glyph identities whose local geometry should be available. + * @param options - Optional source scope; omitted means every authored layer for each glyph. + */ + async ensureGlyphs(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); + } + } + } + } + + /** + * Starts a background glyph-geometry request and logs failures. + * + * @param glyphIds - Stable glyph identities whose local geometry should be requested. + * @param options - Optional source scope; omitted means every authored layer for each glyph. + */ + requestGlyphs(glyphIds: readonly GlyphId[], options: GlyphLoadOptions = {}): void { + void this.ensureGlyphs(glyphIds, options).catch((error) => { + console.error("failed to load glyph snapshots", error); + }); + } + + #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); + } + + 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); + } + } + + 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); + } + } + } + } + } + + async #readAndApplyGlyphRequests( + requests: readonly WorkspaceGlyphSnapshotRequest[], + ): Promise { + if (!this.#editCoordinator) return; + + const load = this.#store.beginGlyphLoad(requests); + try { + this.#store.finishGlyphLoad(load, await this.#editCoordinator.readGlyphSnapshots(requests)); + } catch (error) { + this.#store.failGlyphLoad(load); + throw error; + } + } + /** * Create a reactive composed outline for a glyph. * @@ -848,3 +1013,11 @@ export class Font { 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 index 7f8bbf03..a5295767 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts @@ -11,23 +11,23 @@ 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 generation = store.sync.markSnapshotsLoading([GLYPH_ID]); + const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); - store.sync.replaceWorkspace(snapshot("document-b", LAYER_B_ID)); - store.sync.applyGlyphSnapshots([GLYPH_ID], [glyphSnapshot(LAYER_A_ID)], generation); + store.replaceWorkspace(snapshot("document-b", LAYER_B_ID)); + store.finishGlyphLoad(load, [glyphSnapshot(LAYER_A_ID)]); - expect(store.glyphSnapshots.snapshotStatus(GLYPH_ID)).toBe("missing"); + 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 generation = store.sync.markSnapshotsLoading([GLYPH_ID]); + const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); - store.sync.markSnapshotsFailed([GLYPH_ID], generation); + store.failGlyphLoad(load); - expect(store.glyphSnapshots.snapshotStatus(GLYPH_ID)).toBe("failed"); + expect(store.snapshotStatus(GLYPH_ID)).toBe("failed"); }); }); diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index b7d54630..ad4c3d1a 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -1,6 +1,5 @@ import type { AppliedChange, - FontIntent, GlyphId, GlyphLayerRecord, GlyphRecord, @@ -12,6 +11,7 @@ import type { } from "@shift/types"; import type { WorkspaceGlyphLayerSnapshot, + WorkspaceGlyphSnapshotRequest, WorkspaceGlyphSnapshot, WorkspaceSnapshot, } from "@shared/workspace/protocol"; @@ -26,51 +26,22 @@ type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; const EMPTY_STATUS: ReadonlyMap = new Map(); -/** Mutates renderer-local font state from the serialized workspace sync lane. */ -export interface FontStoreSyncPort { - readonly settledCell: Signal; - readonly commitStateCell: Signal; - - replaceWorkspace(snapshot: WorkspaceSnapshot | null): void; - enqueueIntent(intent: FontIntent): void; - hasPendingIntents(): boolean; - takePendingIntents(): FontIntent[]; - beginApplying(): void; - markSettledIfIdle(busy: number): void; - markSnapshotsLoading(glyphIds: readonly GlyphId[]): number; - markSnapshotsFailed(glyphIds: readonly GlyphId[], generation: number): void; - applyGlyphSnapshots( - requestedGlyphIds: readonly GlyphId[], - snapshots: readonly WorkspaceGlyphSnapshot[], - generation: number, - ): void; - foldAppliedChange(applied: AppliedChange): void; -} - -/** Exposes glyph snapshot freshness and dependency reads to the snapshot loader. */ -export interface GlyphSnapshotStorePort { - sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[]; - snapshotStatus(glyphId: GlyphId): GlyphSnapshotStatus; - hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean; - hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean; - loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[]; +export interface GlyphLoadBatch { + readonly generation: number; + readonly glyphIds: readonly GlyphId[]; } /** - * Renderer-local owner for font records, loaded glyph snapshots, and pending - * local commits. + * Renderer-local owner for font records, loaded glyph snapshots, model caches, + * and snapshot status. * - * Model reads stay on the store. Workspace mutation enters through - * {@link sync}; snapshot request coordination reads through - * {@link glyphSnapshots}. + * 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 #settledCell = signal(true); - readonly #commitState = signal("idle", { - name: "workspace.commitState", - }); readonly #layerStates = new Map(); readonly #layerByGlyphSource = new Map(); @@ -82,31 +53,6 @@ export class FontStore { readonly #snapshotGeneration = new Map(); #generation = 0; - #pendingIntents: FontIntent[] = []; - - readonly sync: FontStoreSyncPort = { - settledCell: this.#settledCell, - commitStateCell: this.#commitState, - replaceWorkspace: (snapshot) => this.#replaceWorkspace(snapshot), - enqueueIntent: (intent) => this.#enqueueIntent(intent), - hasPendingIntents: () => this.#hasPendingIntents(), - takePendingIntents: () => this.#takePendingIntents(), - beginApplying: () => this.#beginApplying(), - markSettledIfIdle: (busy) => this.#markSettledIfIdle(busy), - markSnapshotsLoading: (glyphIds) => this.#markSnapshotsLoading(glyphIds), - markSnapshotsFailed: (glyphIds, generation) => this.#markSnapshotsFailed(glyphIds, generation), - applyGlyphSnapshots: (requestedGlyphIds, snapshots, generation) => - this.#applyGlyphSnapshots(requestedGlyphIds, snapshots, generation), - foldAppliedChange: (applied) => this.#foldAppliedChange(applied), - }; - - readonly glyphSnapshots: GlyphSnapshotStorePort = { - sourceIdsForGlyph: (glyphId) => this.#sourceIdsForGlyph(glyphId), - snapshotStatus: (glyphId) => this.#snapshotStatusForGlyph(glyphId), - hasLayerSnapshot: (glyphId, sourceId) => this.#hasLayerSnapshot(glyphId, sourceId), - hasLayerRecord: (glyphId, sourceId) => this.#hasLayerRecord(glyphId, sourceId), - loadedComponentBaseGlyphIds: (glyphId) => this.#loadedComponentBaseGlyphIds(glyphId), - }; constructor(workspace: WorkspaceSnapshot | null = null) { this.#workspace = signal(workspace, { name: "fontStore.workspace" }); @@ -118,7 +64,11 @@ export class FontStore { return this.#workspace; } - #replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { + get snapshotStatusCell(): Signal> { + return this.#snapshotStatus; + } + + replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { batch(() => { this.#generation += 1; this.#workspace.set(snapshot); @@ -129,77 +79,42 @@ export class FontStore { this.#variationDataByGlyph.clear(); this.#snapshotGeneration.clear(); this.#snapshotStatus.set(EMPTY_STATUS); - this.#pendingIntents = []; - this.#settledCell.set(true); - this.#commitState.set("idle"); }); } - #enqueueIntent(intent: FontIntent): void { - this.#pendingIntents.push(intent); - this.#settledCell.set(false); - if (this.#commitState.peek() === "idle") { - this.#commitState.set("queued"); - } - } - - #hasPendingIntents(): boolean { - return this.#pendingIntents.length > 0; - } - - #takePendingIntents(): FontIntent[] { - const intents = this.#pendingIntents; - this.#pendingIntents = []; - return intents; - } - - #beginApplying(): void { - this.#commitState.set("applying"); - } - - #markSettledIfIdle(busy: number): void { - if (busy === 0 && this.#pendingIntents.length === 0) { - this.#settledCell.set(true); - this.#commitState.set("idle"); - } - } - - #markSnapshotsLoading(glyphIds: readonly GlyphId[]): number { + 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; + return { generation, glyphIds }; } - #markSnapshotsFailed(glyphIds: readonly GlyphId[], generation: number): void { - if (generation !== this.#generation) return; + failGlyphLoad(batch: GlyphLoadBatch): void { + if (batch.generation !== this.#generation) return; const next = new Map(this.#snapshotStatus.peek()); - for (const glyphId of glyphIds) { - if (this.#snapshotGeneration.get(glyphId) === generation) { + for (const glyphId of batch.glyphIds) { + if (this.#snapshotGeneration.get(glyphId) === batch.generation) { next.set(glyphId, "failed"); } } this.#snapshotStatus.set(next); } - #applyGlyphSnapshots( - requestedGlyphIds: readonly GlyphId[], - snapshots: readonly WorkspaceGlyphSnapshot[], - generation: number, - ): void { - if (generation !== this.#generation) return; + 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) !== generation) continue; + if (this.#snapshotGeneration.get(snapshot.glyphId) !== load.generation) continue; if (snapshot.variationData) { this.#variationDataByGlyph.set(snapshot.glyphId, snapshot.variationData); @@ -213,8 +128,8 @@ export class FontStore { nextStatus.set(snapshot.glyphId, "loaded"); } - for (const glyphId of requestedGlyphIds) { - if (!received.has(glyphId) && this.#snapshotGeneration.get(glyphId) === generation) { + for (const glyphId of load.glyphIds) { + if (!received.has(glyphId) && this.#snapshotGeneration.get(glyphId) === load.generation) { nextStatus.set(glyphId, "missing"); } } @@ -223,7 +138,7 @@ export class FontStore { }); } - #foldAppliedChange(applied: AppliedChange): void { + applyWorkspaceChange(applied: AppliedChange): void { const current = this.#workspace.peek(); if (!current) return; @@ -316,23 +231,29 @@ export class FontStore { return created; } - #sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { + sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; } - #snapshotStatusForGlyph(glyphId: GlyphId): GlyphSnapshotStatus { + snapshotStatus(glyphId: GlyphId): GlyphSnapshotStatus { return this.#snapshotStatus.peek().get(glyphId) ?? "missing"; } - #hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean { + hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean { return this.#layerStateForGlyphSource(glyphId, sourceId) !== null; } - #hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean { + hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean { return this.#layerByGlyphSource.has(glyphSourceKey(glyphId, sourceId)); } - #loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { + 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)) { 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 f7eae342..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,7 @@ describe("Glyph", () => { editor = new TestEditor(); await editor.startSession(); layer = editor.editingGlyphLayer!; - const record = editor.font.recordForName(editor.rootGlyphHandle!.name)!; + const record = editor.font.recordForName("A" as GlyphName)!; glyph = editor.font.glyphForId(record.id)!; }); @@ -181,7 +181,7 @@ describe("glyph layers keep public geometry coherent across position edits", () editor = new TestEditor(); await editor.startSession(); layer = editor.editingGlyphLayer!; - const record = editor.font.recordForName(editor.rootGlyphHandle!.name)!; + const record = editor.font.recordForName("A" as GlyphName)!; glyph = editor.font.glyphForId(record.id)!; }); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts deleted file mode 100644 index 8fef7dcb..00000000 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - Axis, - AxisId, - ComponentId, - GlyphId, - GlyphName, - GlyphState, - LayerId, - SourceId, - Unicode, -} from "@shift/types"; -import type { - WorkspaceGlyphSnapshot, - WorkspaceGlyphSnapshotRequest, - WorkspaceSnapshot, -} from "@shared/workspace/protocol"; -import { FontStore, type FontStoreSyncPort } from "./FontStore"; -import { GlyphSnapshotLoader, type GlyphSnapshotSyncPort } from "./GlyphSnapshotLoader"; - -const SOURCE_ID = "source_regular" as SourceId; -const AXIS_ID = "axis_weight" as AxisId; -const GLYPH_A_ID = "glyph_a" as GlyphId; -const GLYPH_B_ID = "glyph_b" as GlyphId; -const LAYER_A_ID = "layer_a" as LayerId; -const LAYER_B_ID = "layer_b" as LayerId; - -describe("glyph snapshot loading follows component dependencies", () => { - it("loads component base glyph snapshots discovered from loaded geometry", async () => { - const store = new FontStore(workspaceSnapshot()); - const sync = new SnapshotFixtureSync(store.sync, [glyphSnapshotA(), glyphSnapshotB()]); - const loader = new GlyphSnapshotLoader(store.glyphSnapshots, sync); - - await loader.load([GLYPH_A_ID]); - - expect(store.glyphSnapshots.snapshotStatus(GLYPH_A_ID)).toBe("loaded"); - expect(store.glyphSnapshots.snapshotStatus(GLYPH_B_ID)).toBe("loaded"); - expect(store.layerState(LAYER_A_ID)?.geometry.components[0]?.baseGlyphId).toBe(GLYPH_B_ID); - expect(store.layerState(LAYER_B_ID)?.xAdvance).toBe(500); - }); - - it("settles queued workspace edits before checking snapshot freshness", async () => { - const store = new FontStore(workspaceSnapshot()); - const generation = store.sync.markSnapshotsLoading([GLYPH_A_ID]); - store.sync.applyGlyphSnapshots([GLYPH_A_ID], [glyphSnapshotA(600)], generation); - const sync = new SnapshotFixtureSync( - store.sync, - [glyphSnapshotA(700), glyphSnapshotB()], - () => { - store.sync.foldAppliedChange({ layers: [], axes: [weightAxis()], dependents: [] }); - }, - ); - const loader = new GlyphSnapshotLoader(store.glyphSnapshots, sync); - - await loader.load([GLYPH_A_ID]); - - expect(store.glyphSnapshots.snapshotStatus(GLYPH_A_ID)).toBe("loaded"); - expect(store.layerState(LAYER_A_ID)?.xAdvance).toBe(700); - }); -}); - -class SnapshotFixtureSync implements GlyphSnapshotSyncPort { - readonly #store: FontStoreSyncPort; - readonly #snapshots: ReadonlyMap; - readonly #settle: () => void; - - constructor( - store: FontStoreSyncPort, - snapshots: readonly WorkspaceGlyphSnapshot[], - settle: () => void = () => {}, - ) { - this.#store = store; - this.#snapshots = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); - this.#settle = settle; - } - - async settled(): Promise { - this.#settle(); - } - - async loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { - const glyphIds = requests.map((request) => request.glyphId); - const generation = this.#store.markSnapshotsLoading(glyphIds); - const snapshots = glyphIds - .map((glyphId) => this.#snapshots.get(glyphId)) - .filter((snapshot): snapshot is WorkspaceGlyphSnapshot => Boolean(snapshot)); - - this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); - } -} - -function workspaceSnapshot(): WorkspaceSnapshot { - return { - documentId: "document", - metadata: { familyName: "Untitled Font" }, - metrics: { unitsPerEm: 1000, ascender: 800, descender: -200 }, - glyphs: [ - { - id: GLYPH_A_ID, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - componentBaseGlyphIds: [GLYPH_B_ID], - layers: [{ id: LAYER_A_ID, sourceId: SOURCE_ID }], - }, - { - id: GLYPH_B_ID, - name: "B" as GlyphName, - unicodes: [66 as Unicode], - componentBaseGlyphIds: [], - layers: [{ id: LAYER_B_ID, sourceId: SOURCE_ID }], - }, - ], - sources: [ - { - id: SOURCE_ID, - name: "Regular", - location: { values: {} }, - }, - ], - axes: [], - }; -} - -function glyphSnapshotA(xAdvance = 600): WorkspaceGlyphSnapshot { - return { - glyphId: GLYPH_A_ID, - layers: [ - { - glyphId: GLYPH_A_ID, - sourceId: SOURCE_ID, - state: componentGlyphState(xAdvance), - }, - ], - }; -} - -function glyphSnapshotB(): WorkspaceGlyphSnapshot { - return { - glyphId: GLYPH_B_ID, - layers: [ - { - glyphId: GLYPH_B_ID, - sourceId: SOURCE_ID, - state: simpleGlyphState(LAYER_B_ID), - }, - ], - }; -} - -function componentGlyphState(xAdvance: number): GlyphState { - return { - layerId: LAYER_A_ID, - structure: { - contours: [], - anchors: [], - components: [ - { - id: "component_b" as ComponentId, - baseGlyphId: GLYPH_B_ID, - baseGlyphName: "B" as GlyphName, - }, - ], - }, - values: new Float64Array([xAdvance, 0, 0, 0, 1, 1, 0, 0, 0, 0]), - }; -} - -function simpleGlyphState(layerId: LayerId): GlyphState { - return { - layerId, - structure: { contours: [], anchors: [], components: [] }, - values: new Float64Array([500]), - }; -} - -function weightAxis(): Axis { - return { - id: AXIS_ID, - tag: "wght", - name: "Weight", - min: 100, - default: 400, - max: 900, - hidden: false, - }; -} diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts b/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts deleted file mode 100644 index 901e329e..00000000 --- a/apps/desktop/src/renderer/src/lib/model/GlyphSnapshotLoader.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { GlyphId, SourceId } from "@shift/types"; -import type { WorkspaceGlyphSnapshotRequest } from "@shared/workspace/protocol"; -import type { GlyphSnapshotStorePort } from "./FontStore"; - -type SnapshotRequest = { - glyphId: GlyphId; - sourceIds: SourceId[]; -}; - -export type GlyphSnapshotLoadOptions = { - readonly sourceIds?: readonly SourceId[]; -}; - -export interface GlyphSnapshotSyncPort { - settled(): Promise; - loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise; -} - -type InFlightKey = string & { readonly __inFlightKey: unique symbol }; - -/** - * Coordinates renderer glyph snapshot reads through the workspace sync lane. - * - * @remarks - * Loaded geometry and freshness state remain in {@link FontStore}; this loader - * only dedupes requests, chooses source scopes, and follows component bases. - */ -export class GlyphSnapshotLoader { - readonly #store: GlyphSnapshotStorePort; - readonly #sync: GlyphSnapshotSyncPort; - readonly #inFlight = new Set(); - - constructor(store: GlyphSnapshotStorePort, sync: GlyphSnapshotSyncPort) { - this.#store = store; - this.#sync = sync; - } - - /** - * Loads missing or stale snapshots for the requested glyphs. - * - * @param glyphIds - stable glyph identities whose local geometry should be available. - * @param options - optional source scope; omitted means every authored layer for each glyph. - */ - async load(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions = {}): Promise { - if (glyphIds.length === 0) return; - await this.#sync.settled(); - - const queue = this.#requestable(glyphIds, options); - const seen = new Set(queue.map((request) => request.glyphId)); - - while (queue.length > 0) { - const batch = queue.splice(0); - for (const request of batch) this.#markInFlight(request); - - try { - await this.#sync.loadGlyphSnapshots(batch); - } finally { - for (const request of batch) this.#unmarkInFlight(request); - } - - for (const request of batch) { - for (const baseGlyphId of this.#store.loadedComponentBaseGlyphIds(request.glyphId)) { - if (seen.has(baseGlyphId)) continue; - const neededSourceIds = this.#neededSourceIds(baseGlyphId, options); - if (neededSourceIds.length === 0) continue; - seen.add(baseGlyphId); - queue.push({ glyphId: baseGlyphId, sourceIds: neededSourceIds }); - } - } - } - } - - /** - * Starts a background snapshot load and logs failures. - * - * @param glyphIds - stable glyph identities whose local geometry should be requested. - * @param options - optional source scope; omitted means every authored layer for each glyph. - */ - request(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions = {}): void { - void this.load(glyphIds, options).catch((error) => { - console.error("failed to load glyph snapshots", error); - }); - } - - #requestable(glyphIds: readonly GlyphId[], options: GlyphSnapshotLoadOptions): SnapshotRequest[] { - const result: SnapshotRequest[] = []; - const seen = new Set(); - for (const glyphId of glyphIds) { - if (seen.has(glyphId)) continue; - const neededSourceIds = this.#neededSourceIds(glyphId, options); - if (neededSourceIds.length === 0) continue; - seen.add(glyphId); - result.push({ glyphId, sourceIds: neededSourceIds }); - } - return result; - } - - #neededSourceIds(glyphId: GlyphId, options: GlyphSnapshotLoadOptions): SourceId[] { - const status = this.#store.snapshotStatus(glyphId); - const shouldReload = status === "stale" || status === "failed"; - const needed: SourceId[] = []; - const seen = new Set(); - const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); - - for (const sourceId of sourceIds) { - if (seen.has(sourceId)) continue; - seen.add(sourceId); - if (!this.#store.hasLayerRecord(glyphId, sourceId)) continue; - if (this.#inFlight.has(inFlightKey(glyphId, sourceId))) continue; - if (shouldReload || !this.#store.hasLayerSnapshot(glyphId, sourceId)) { - needed.push(sourceId); - } - } - - return needed; - } - - #markInFlight(request: SnapshotRequest): void { - for (const sourceId of request.sourceIds) { - this.#inFlight.add(inFlightKey(request.glyphId, sourceId)); - } - } - - #unmarkInFlight(request: SnapshotRequest): void { - for (const sourceId of request.sourceIds) { - this.#inFlight.delete(inFlightKey(request.glyphId, sourceId)); - } - } -} - -function inFlightKey(glyphId: GlyphId, sourceId: SourceId): InFlightKey { - return `${glyphId}:${sourceId}` as InFlightKey; -} 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 37645625..272f81de 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -135,14 +135,14 @@ async function variableFont(): Promise<{ } async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { - await stack.glyphSnapshotLoader.load([glyphId], { sourceIds: [stack.font.defaultSource.id] }); + await stack.font.ensureGlyphs([glyphId], { sourceIds: [stack.font.defaultSource.id] }); const glyph = stack.font.glyphForId(glyphId); if (!glyph) throw new Error("Expected default glyph layer to load"); return glyph; } async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { - await stack.glyphSnapshotLoader.load([glyphId], { + await stack.font.ensureGlyphs([glyphId], { sourceIds: [stack.font.defaultSource.id, source.id], }); const layer = stack.font.glyphLayerForId(glyphId, source.id); 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 6b622e38..f8c92653 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -47,7 +47,7 @@ export async function layoutTestFont(): Promise { await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId, width: advance } }, ]); - await stack.glyphSnapshotLoader.load([record.id]); + await stack.font.ensureGlyphs([record.id]); } const record = stack.font.recordForName("A" as GlyphName); 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 42e5a591..3441c020 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; @@ -33,7 +27,7 @@ export class TextTool extends BaseTool { const ownerName = owner.name; const record = this.editor.font.recordForName(ownerName); - if (record) this.editor.requestGlyphSnapshots([record.id]); + if (record) this.editor.font.requestGlyphs([record.id]); const run = this.editor.textRuns.switchTo(ownerName); run.seed(glyphTextItem(ownerName, owner.unicode ?? null), this.editor.drawOffset.x); 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 54aa53e0..38aa7e7e 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -61,10 +61,10 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => }); it("marks snapshot loads after queued workspace summary edits flush", async () => { - const { font, glyphSnapshotLoader, store, workspaceSync } = stack; + const { font, store, editCoordinator } = stack; const glyphId = mintGlyphId(); const layerId = mintLayerId(); - await workspaceSync.apply([ + await editCoordinator.apply([ { kind: "createGlyph", createGlyph: { glyphId, name: "D" as GlyphName, unicodes: [68 as Unicode] }, @@ -74,10 +74,10 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => createGlyphLayer: { layerId, glyphId, sourceId: font.defaultSource.id }, }, ]); - await glyphSnapshotLoader.load([glyphId]); + await font.ensureGlyphs([glyphId]); const axisId = mintAxisId(); - workspaceSync.push({ + editCoordinator.push({ kind: "createAxis", createAxis: { axisId, @@ -90,9 +90,9 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => }, }); - await glyphSnapshotLoader.load([glyphId]); + await font.ensureGlyphs([glyphId]); expect(font.getAxes().map((axis) => axis.id)).toEqual([axisId]); - expect(store.glyphSnapshots.snapshotStatus(glyphId)).toBe("loaded"); + 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 f7e488fa..6300b2ef 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -1,10 +1,11 @@ import type { AppliedChange, FontIntent } from "@shift/types"; import type { WorkspaceDocumentState, + WorkspaceGlyphSnapshot, WorkspaceGlyphSnapshotRequest, } from "@shared/workspace/protocol"; -import type { Signal } from "@/lib/signals/signal"; -import type { FontStoreSyncPort, WorkspaceCommitState } from "@/lib/model/FontStore"; +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 } from "@/lib/model/FontStore"; @@ -27,15 +28,22 @@ export type { WorkspaceCommitState } from "@/lib/model/FontStore"; */ export class WorkspaceEditCoordinator { readonly #workspace: WorkspaceClient; - readonly #store: FontStoreSyncPort; + readonly #store: FontStore; + readonly #settledCell: WritableSignal; + readonly #commitState: WritableSignal; #flushQueued = false; #chain: Promise = Promise.resolve(); #busy = 0; + #pendingIntents: FontIntent[] = []; - constructor(workspace: WorkspaceClient, store: FontStoreSyncPort) { + constructor(workspace: WorkspaceClient, store: FontStore) { this.#workspace = workspace; this.#store = store; + this.#settledCell = signal(true); + this.#commitState = signal("idle", { + name: "workspace.commitState", + }); } /** @@ -43,7 +51,7 @@ export class WorkspaceEditCoordinator { * indicator: un-echoed state must never read as durable. */ get settledCell(): Signal { - return this.#store.settledCell; + return this.#settledCell; } /** @@ -55,12 +63,16 @@ export class WorkspaceEditCoordinator { * the utility process has echoed the new dirty state. */ get commitStateCell(): Signal { - return this.#store.commitStateCell; + return this.#commitState; } /** Queues one intent; everything in the same microtask becomes one apply. */ push(intent: FontIntent): void { - this.#store.enqueueIntent(intent); + this.#pendingIntents.push(intent); + this.#settledCell.set(false); + if (this.#commitState.peek() === "idle") { + this.#commitState.set("queued"); + } if (!this.#flushQueued) { this.#flushQueued = true; @@ -70,7 +82,7 @@ export class WorkspaceEditCoordinator { /** Resolves when every queued and in-flight operation has settled. */ async settled(): Promise { - while (this.#store.hasPendingIntents() || this.#busy > 0) { + while (this.#pendingIntents.length > 0 || this.#busy > 0) { this.#enqueueFlush(); await this.#chain; } @@ -79,7 +91,7 @@ export class WorkspaceEditCoordinator { apply(intents: FontIntent[]): Promise { return this.#withFlush(async () => { const applied = await this.#workspace.apply(intents); - this.#store.foldAppliedChange(applied); + this.#store.applyWorkspaceChange(applied); return applied; }); } @@ -88,33 +100,24 @@ export class WorkspaceEditCoordinator { undo(): Promise { return this.#withFlush(async () => { const applied = await this.#workspace.undo(); - if (applied) this.#store.foldAppliedChange(applied); + if (applied) this.#store.applyWorkspaceChange(applied); return applied; }); } /** Pulls replace-grade glyph snapshots by glyph id, serialized behind pending writes. */ - async loadGlyphSnapshots(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { - if (requests.length === 0) return; - - const glyphIds = requests.map((request) => request.glyphId); - await this.#withFlush(async () => { - const generation = this.#store.markSnapshotsLoading(glyphIds); - try { - const snapshots = await this.#workspace.glyphSnapshots(requests); - this.#store.applyGlyphSnapshots(glyphIds, snapshots, generation); - } catch (error) { - this.#store.markSnapshotsFailed(glyphIds, generation); - throw error; - } - }); + 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.#store.foldAppliedChange(applied); + if (applied) this.#store.applyWorkspaceChange(applied); return applied; }); } @@ -142,15 +145,16 @@ export class WorkspaceEditCoordinator { #enqueueFlush(): void { this.#flushQueued = false; - if (!this.#store.hasPendingIntents()) return; + if (this.#pendingIntents.length === 0) return; - const intents = this.#store.takePendingIntents(); + const intents = this.#pendingIntents; + this.#pendingIntents = []; void this.#serialize(async () => { try { - this.#store.beginApplying(); + this.#commitState.set("applying"); const applied = await this.#workspace.apply(intents); - this.#store.foldAppliedChange(applied); + this.#store.applyWorkspaceChange(applied); } catch (error) { console.error("workspace apply failed; resyncing from truth", error); await this.#resync(); @@ -172,7 +176,10 @@ export class WorkspaceEditCoordinator { #afterJob(): void { this.#busy -= 1; - this.#store.markSettledIfIdle(this.#busy); + if (this.#busy === 0 && this.#pendingIntents.length === 0) { + this.#settledCell.set(true); + this.#commitState.set("idle"); + } } /** Recovery: discard loaded projections and reload the workspace summary from utility. */ diff --git a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts index 76d174df..ff8182ff 100644 --- a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts +++ b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts @@ -81,7 +81,7 @@ describe("workspace apply round trip (channel + NAPI + SQLite)", () => { }); bench("replace-grade glyph state pull", async () => { - await stack.editCoordinator.loadGlyphSnapshots([ + 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 5aaf3f1e..99b3ff99 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"; @@ -43,7 +43,7 @@ export class TestEditor extends Editor { constructor() { const stack = createWorkspaceStack(); const clipboard = new InMemorySystemClipboard(); - super({ font: stack.font, glyphSnapshotLoader: stack.glyphSnapshotLoader, clipboard }); + super({ font: stack.font, clipboard }); this.#stack = stack; this.#clipboard = clipboard; registerBuiltInTools(this); @@ -59,7 +59,7 @@ 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"); - await this.openGlyphRoute(record.id).ready; + this.#placeGlyph(record.id); return this; } @@ -93,12 +93,18 @@ 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"); - await this.#stack.glyphSnapshotLoader.load([record.id], { sourceIds: [sourceId] }); + await this.font.ensureGlyphs([record.id], { sourceIds: [sourceId] }); const glyph = this.font.glyphForId(record.id); 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 4cf29ce3..b81ca23e 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -7,7 +7,6 @@ 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 { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; @@ -15,7 +14,6 @@ export type WorkspaceStack = { client: WorkspaceClient; store: FontStore; editCoordinator: WorkspaceEditCoordinator; - glyphSnapshotLoader: GlyphSnapshotLoader; font: Font; createWorkspace(): Promise; }; @@ -45,15 +43,13 @@ export function createWorkspaceStack(): WorkspaceStack { }, }); const store = new FontStore(); - const editCoordinator = new WorkspaceEditCoordinator(client, store.sync); - const glyphSnapshotLoader = new GlyphSnapshotLoader(store.glyphSnapshots, editCoordinator); + const editCoordinator = new WorkspaceEditCoordinator(client, store); const font = new Font(store, editCoordinator); return { client, store, editCoordinator, - glyphSnapshotLoader, font, async createWorkspace(): Promise { await shell.call("workspace.create", undefined); @@ -64,7 +60,7 @@ export function createWorkspaceStack(): WorkspaceStack { throw new Error("workspace stack connected without a snapshot"); } - store.sync.replaceWorkspace(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 2cb205c2..1105b2fc 100644 --- a/apps/desktop/src/renderer/src/workspace/Workspace.ts +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -4,7 +4,6 @@ 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 { GlyphSnapshotLoader } from "@/lib/model/GlyphSnapshotLoader"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; import { @@ -28,26 +27,20 @@ export class Workspace { readonly font: Font; readonly editor: Editor; - readonly glyphSnapshotLoader: GlyphSnapshotLoader; readonly documentStateCell: Signal; readonly commitStateCell: Signal; constructor(options: WorkspaceOptions) { this.#client = new WorkspaceClient(options.host); this.#store = new FontStore(); - this.#edits = new WorkspaceEditCoordinator(this.#client, this.#store.sync); + this.#edits = new WorkspaceEditCoordinator(this.#client, this.#store); this.#documentBridge = new WorkspaceDocumentBridge({ host: options.host, edits: this.#edits, }); this.font = new Font(this.#store, this.#edits); - this.glyphSnapshotLoader = new GlyphSnapshotLoader(this.#store.glyphSnapshots, this.#edits); - this.editor = new Editor({ - font: this.font, - glyphSnapshotLoader: this.glyphSnapshotLoader, - clipboard: options.clipboard, - }); + this.editor = new Editor({ font: this.font, clipboard: options.clipboard }); this.documentStateCell = this.#client.documentStateCell; this.commitStateCell = this.#edits.commitStateCell; @@ -77,7 +70,7 @@ export class Workspace { throw new Error("workspace connected without a snapshot"); } - this.#store.sync.replaceWorkspace(snapshot); + this.#store.replaceWorkspace(snapshot); await this.#documentBridge.connect(); } catch (error) { this.#connection = null; From 4aaae8aa153571a96e6f926e77b11346dd452dbb Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 22 Jun 2026 19:26:28 +0000 Subject: [PATCH 11/14] Refactor glyph loading for grid previews --- .../renderer/src/components/editor/Canvas.tsx | 6 +- .../src/components/home/GlyphGrid.tsx | 117 ++++++++++++++---- .../src/components/home/GlyphPreview.tsx | 96 +++++++------- .../src/components/variation/Sources.tsx | 4 +- .../src/renderer/src/lib/editor/Editor.ts | 16 ++- .../src/renderer/src/lib/model/Font.test.ts | 16 ++- .../src/renderer/src/lib/model/Font.ts | 50 +++++--- .../renderer/src/lib/model/variation.test.ts | 7 +- .../renderer/src/lib/text/layout/testUtils.ts | 2 +- .../src/renderer/src/lib/tools/text/Text.ts | 6 +- .../WorkspaceEditCoordinator.test.ts | 4 +- .../src/renderer/src/testing/TestEditor.ts | 3 +- 12 files changed, 216 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index 812f392b..f24afc9b 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -20,10 +20,9 @@ export const Canvas: FC = () => { const containerRef = useRef(null); const cursorStyle = useSignalState(editor.cursorCell); - const fontLoaded = useSignalState(editor.font.$loaded); useEffect(() => { - if (!fontLoaded || !glyphIdParam) { + if (!glyphIdParam) { editor.scene.clear(); return undefined; } @@ -37,8 +36,9 @@ export const Canvas: FC = () => { editor.scene.clear(); const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); editor.scene.setGeometryItems([itemId]); + return () => editor.scene.clear(); - }, [editor, fontLoaded, glyphIdParam]); + }, [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 5e5aed5f..dec66851 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,41 @@ 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) { + if (glyph.exists) glyphIds.push(glyph.id); + } + } + return glyphIds; +} + +function mergeLoadedGlyphs( + current: ReadonlyMap, + loaded: ReadonlyMap, +): ReadonlyMap { + let next: Map | null = null; + for (const [glyphId, glyph] of loaded) { + if (current.get(glyphId) === glyph) continue; + next ??= new Map(current); + next.set(glyphId, glyph); + } + return next ?? current; +} + 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,14 +137,35 @@ 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(() => { + if (visibleGlyphIds.length === 0) return; + + async function loadVisibleGlyphs() { + const nextGlyphs = await font.loadGlyphs(visibleGlyphIds); + setLoadedGlyphs((current) => mergeLoadedGlyphs(current, nextGlyphs)); + } + + loadVisibleGlyphs().catch((error) => { + console.error("failed to load visible glyphs", error); + }); + }, [font, visibleGlyphIds]); const handleCellClick = useCallback( async (glyph: GlyphCatalogItem) => { - if (!glyph.id) return; - await editor.font.ensureGlyphs([glyph.id]); + if (!glyph.exists) return; + const loadedGlyph = await font.loadGlyph(glyph.id); + if (!loadedGlyph) return; navigate(`/editor/${encodeURIComponent(glyph.id)}`); }, - [editor, navigate], + [font, navigate], ); return ( @@ -131,7 +185,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 ( @@ -146,27 +200,36 @@ export const GlyphGrid = memo(function GlyphGrid() { }} className="flex gap-2 px-4" > - {rowGlyphs.map((glyph) => ( -
- - -
- ))} + + + + ); + })} ); })} diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index d7a8745b..675a50c2 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -1,10 +1,7 @@ -import { useEffect } from "react"; 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; @@ -46,50 +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) { - const fontLoaded = useSignalState(font.$loaded); - const record = fontLoaded ? font.recordForName(handle.name) : null; - const recordId = record?.id ?? null; - - useEffect(() => { - if (!recordId) return; - font.requestGlyphs([recordId]); - }, [font, recordId]); - - if (!fontLoaded) { - return ; - } - - const glyph = recordId ? font.glyphForId(recordId) : null; +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 (
@@ -109,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 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={ { this.#text.glyphAnchor.set(anchor); const record = this.font.recordForName(focused.glyph.name); - if (record) this.font.requestGlyphs([record.id]); + if (record) { + this.font.loadGlyph(record.id).catch((error) => { + console.error("failed to load focused text glyph", error); + }); + } this.disableProofMode(); }); } @@ -647,7 +651,7 @@ export class Editor { * 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; @@ -657,7 +661,7 @@ export class Editor { /** * Return the shared design location to the font default. */ - public clearLayerSourceSelection(): void { + public setSourceToDefault(): void { this.setDesignLocation(this.font.defaultLocation()); } @@ -675,7 +679,11 @@ export class Editor { const handle = this.font.glyphHandleForUnicode(codepoint); if (!handle) return; const record = this.font.recordForName(handle.name); - if (record) this.font.requestGlyphs([record.id]); + 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)); } 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 10a0f378..cbc373b7 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -192,8 +192,7 @@ describe("font-level intents make the font variable", () => { expect(committed?.layers).toEqual(record.layers); expect(stack.font.layerRecordForId(record.id, source.id)).toEqual(record.layers[0]); - await stack.font.ensureGlyphs([record.id], { sourceIds: [source.id] }); - const glyph = stack.font.glyphForId(record.id); + const glyph = await stack.font.loadGlyph(record.id, { sourceIds: [source.id] }); expect(glyph?.xAdvance).toBe(stack.font.defaultXAdvance); }); @@ -203,9 +202,7 @@ describe("font-level intents make the font variable", () => { const record = stack.font.createGlyph("A" as GlyphName); await stack.editCoordinator.settled(); - await stack.font.ensureGlyphs([record.id]); - - const glyph = stack.font.glyphForId(record.id); + const glyph = await stack.font.loadGlyph(record.id); if (!glyph) throw new Error("Expected loaded glyph"); await stack.editCoordinator.apply([ @@ -272,8 +269,9 @@ describe("font-level intents make the font variable", () => { }, ]); - await stack.font.ensureGlyphs([glyphId], { sourceIds: [stack.font.defaultSource.id] }); - const glyph = stack.font.glyphForId(glyphId); + 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); @@ -340,8 +338,8 @@ describe("font-level intents make the font variable", () => { const boldSource = stack.font.source(boldSourceId); if (!regularSource || !boldSource) throw new Error("Expected both sources"); - const regularLoad = stack.font.ensureGlyphs([glyphId], { sourceIds: [defaultSourceId] }); - const boldLoad = stack.font.ensureGlyphs([glyphId], { sourceIds: [boldSourceId] }); + 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); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 65786955..e55681c9 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -684,12 +684,46 @@ export class Font { } /** - * Loads missing or stale glyph geometry and discovered component bases. + * Loads one glyph's geometry and returns its live model. + * + * @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. + * + * @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. */ - async ensureGlyphs(glyphIds: readonly GlyphId[], options: GlyphLoadOptions = {}): Promise { + 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; + } + + async #loadGlyphSnapshots( + glyphIds: readonly GlyphId[], + options: GlyphLoadOptions = {}, + ): Promise { if (!this.#editCoordinator || glyphIds.length === 0) return; await this.#editCoordinator.settled(); @@ -719,18 +753,6 @@ export class Font { } } - /** - * Starts a background glyph-geometry request and logs failures. - * - * @param glyphIds - Stable glyph identities whose local geometry should be requested. - * @param options - Optional source scope; omitted means every authored layer for each glyph. - */ - requestGlyphs(glyphIds: readonly GlyphId[], options: GlyphLoadOptions = {}): void { - void this.ensureGlyphs(glyphIds, options).catch((error) => { - console.error("failed to load glyph snapshots", error); - }); - } - #requestableGlyphs( glyphIds: readonly GlyphId[], options: GlyphLoadOptions, 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 272f81de..ff6c0af5 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -135,14 +135,15 @@ async function variableFont(): Promise<{ } async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { - await stack.font.ensureGlyphs([glyphId], { sourceIds: [stack.font.defaultSource.id] }); - const glyph = stack.font.glyphForId(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.ensureGlyphs([glyphId], { + await stack.font.loadGlyph(glyphId, { sourceIds: [stack.font.defaultSource.id, source.id], }); const layer = stack.font.glyphLayerForId(glyphId, source.id); 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 f8c92653..06909414 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -47,7 +47,7 @@ export async function layoutTestFont(): Promise { await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId, width: advance } }, ]); - await stack.font.ensureGlyphs([record.id]); + await stack.font.loadGlyph(record.id); } const record = stack.font.recordForName("A" as GlyphName); 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 3441c020..0d2bfa9b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -27,7 +27,11 @@ export class TextTool extends BaseTool { const ownerName = owner.name; const record = this.editor.font.recordForName(ownerName); - if (record) this.editor.font.requestGlyphs([record.id]); + 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); 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 38aa7e7e..f9ec188e 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -74,7 +74,7 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => createGlyphLayer: { layerId, glyphId, sourceId: font.defaultSource.id }, }, ]); - await font.ensureGlyphs([glyphId]); + await font.loadGlyph(glyphId); const axisId = mintAxisId(); editCoordinator.push({ @@ -90,7 +90,7 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => }, }); - await font.ensureGlyphs([glyphId]); + 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/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 99b3ff99..7465d075 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -93,8 +93,7 @@ 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"); - await this.font.ensureGlyphs([record.id], { sourceIds: [sourceId] }); - const glyph = this.font.glyphForId(record.id); + const glyph = await this.font.loadGlyph(record.id, { sourceIds: [sourceId] }); if (!glyph) throw new Error("glyphForId returned null for a loaded glyph"); return glyph; } From 33ee2139eb526e37ebbcf2bae4e7908b5ed1b5f4 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 22 Jun 2026 20:36:54 +0100 Subject: [PATCH 12/14] Surface font load failures --- .../src/main/document/DocumentSession.ts | 119 +++++++++++++++++- crates/shift-source/src/package.rs | 10 +- crates/shift-store/src/error.rs | 6 +- crates/shift-workspace/src/workspace.rs | 10 +- 4 files changed, 126 insertions(+), 19 deletions(-) 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/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-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), } From dbf0433f455bfc164f98fdd10db45bc5261094a9 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 22 Jun 2026 20:40:05 +0100 Subject: [PATCH 13/14] Skip package recovery for font imports --- apps/desktop/src/utility/workspace/WorkspaceHost.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 83b02cd9..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, @@ -128,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) { @@ -194,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"; +} From 3d64f263247b30c7971154fd95cf31b5256c9165 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 23 Jun 2026 21:04:21 +0100 Subject: [PATCH 14/14] wip --- .../src/components/home/GlyphGrid.tsx | 55 +++++++++---------- .../home/glyph-catalog/GlyphCatalog.tsx | 9 ++- .../src/context/GlyphCatalogContext.tsx | 36 +++--------- 3 files changed, 39 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index dec66851..a642cf6b 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -76,25 +76,12 @@ function visibleGlyphIdsForRows( const startIndex = row.index * columns; const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); for (const glyph of rowGlyphs) { - if (glyph.exists) glyphIds.push(glyph.id); + glyphIds.push(glyph.id); } } return glyphIds; } -function mergeLoadedGlyphs( - current: ReadonlyMap, - loaded: ReadonlyMap, -): ReadonlyMap { - let next: Map | null = null; - for (const [glyphId, glyph] of loaded) { - if (current.get(glyphId) === glyph) continue; - next ??= new Map(current); - next.set(glyphId, glyph); - } - return next ?? current; -} - export const GlyphGrid = memo(function GlyphGrid() { const navigate = useNavigate(); const editor = useEditor(); @@ -146,24 +133,35 @@ export const GlyphGrid = memo(function GlyphGrid() { const [loadedGlyphs, setLoadedGlyphs] = useState>(() => new Map()); useEffect(() => { - if (visibleGlyphIds.length === 0) return; + let cancelled = false; - async function loadVisibleGlyphs() { - const nextGlyphs = await font.loadGlyphs(visibleGlyphIds); - setLoadedGlyphs((current) => mergeLoadedGlyphs(current, nextGlyphs)); + 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()); + } } - loadVisibleGlyphs().catch((error) => { - console.error("failed to load visible glyphs", error); - }); + void load(); + + return () => { + cancelled = true; + }; }, [font, visibleGlyphIds]); const handleCellClick = useCallback( async (glyph: GlyphCatalogItem) => { - if (!glyph.exists) return; - const loadedGlyph = await font.loadGlyph(glyph.id); - if (!loadedGlyph) return; - navigate(`/editor/${encodeURIComponent(glyph.id)}`); + 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); + } }, [font, navigate], ); @@ -201,10 +199,10 @@ export const GlyphGrid = memo(function GlyphGrid() { className="flex gap-2 px-4" > {rowGlyphs.map((glyph) => { - const previewGlyph = glyph.exists ? (loadedGlyphs.get(glyph.id) ?? null) : null; + const previewGlyph = loadedGlyphs.get(glyph.id) ?? null; return (
{ const next = draft.trim() as GlyphName; - if (!glyph.exists || next === glyphName) { + if (next === glyphName) { setDraft(glyphName); return; } @@ -268,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/glyph-catalog/GlyphCatalog.tsx b/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx index 26a37add..07faa4d8 100644 --- a/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx +++ b/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx @@ -17,7 +17,7 @@ import { SubCategory } from "./SubCategory"; export const GlyphCatalog = () => { 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/context/GlyphCatalogContext.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx index 4b23c7f4..1a33178d 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx @@ -5,19 +5,11 @@ import { useSignalState } from "@/lib/signals"; import { useEditor } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; -export type GlyphCatalogItem = - | { - readonly id: GlyphId; - readonly name: GlyphName; - readonly unicode: number | null; - readonly exists: true; - } - | { - readonly id: null; - readonly name: GlyphName; - readonly unicode: number | null; - readonly exists: false; - }; +export type GlyphCatalogItem = { + readonly id: GlyphId; + readonly name: GlyphName; + readonly unicode: number | null; +}; export interface GlyphCatalogState { availableGlyphs: GlyphCatalogItem[]; @@ -51,28 +43,15 @@ const useGlyphCatalogState = (): GlyphCatalogState => { 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, }; }